Compare commits
37 Commits
d6b7504408
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 341d663b36 | |||
| cbc03c0674 | |||
| bd8ce61897 | |||
| 0da90f976f | |||
| 6ae62a60fc | |||
| afa6ac22ba | |||
| d5c8dccc34 | |||
| 82bc0c78fe | |||
| 4b47611902 | |||
| 0b4931eaca | |||
| 6c7a78ce76 | |||
| be5a5db18a | |||
| e722680863 | |||
| 0d8415ba4e | |||
| b3055d8f1a | |||
| efc007e487 | |||
| 469535f79a | |||
| 11c59b890e | |||
| 17f605cf5f | |||
| 07896959ce | |||
| a52b58c176 | |||
| 2134a53921 | |||
| 41fc6fdd86 | |||
| ab53bee5c4 | |||
| 2db7eea9f1 | |||
| 1f91d7508e | |||
| 5de087e8e0 | |||
| a3a15b0644 | |||
| 68398e5b60 | |||
| 8cf24d8c2a | |||
| 76d97e202b | |||
| 90b0a19a4d | |||
| 56dc8337af | |||
| 6b611b1d09 | |||
| cb86d52705 | |||
| ea44f0bba4 | |||
| 984e8715d7 |
17
.gitignore
vendored
17
.gitignore
vendored
@@ -9,7 +9,22 @@ build
|
||||
/sandbox-test/test_results.json
|
||||
|
||||
# Test output files
|
||||
screenshot_*.png
|
||||
/tests/
|
||||
*.png
|
||||
test_*.json
|
||||
test_*.txt
|
||||
test_*.log
|
||||
*_hierarchy.json
|
||||
recorded_actions.json
|
||||
|
||||
# Junctions to src/main/assets/ for base-apps testing
|
||||
# Create with: mklink /J icons src\main\assets\icons (etc.)
|
||||
/icons
|
||||
/scripts
|
||||
/ui
|
||||
|
||||
# Sandbox data created during testing
|
||||
/src/main/assets/sandbox_data/
|
||||
|
||||
# Misc
|
||||
NUL
|
||||
|
||||
307
base-apps/com.mosis.browser/browser.lua
Normal file
307
base-apps/com.mosis.browser/browser.lua
Normal file
@@ -0,0 +1,307 @@
|
||||
-- browser.lua - Web browser functionality
|
||||
-- Handles URL navigation, tabs, bookmarks, and history
|
||||
|
||||
local browser_doc = nil
|
||||
local tabs = {}
|
||||
local current_tab_id = 1
|
||||
local history = {}
|
||||
local bookmarks = {}
|
||||
|
||||
-- Sample page content
|
||||
local pages = {
|
||||
["example.com"] = {
|
||||
title = "Example Domain",
|
||||
secure = true,
|
||||
content = [[
|
||||
<div class="browser-page-title">Example Domain</div>
|
||||
<div class="browser-page-text">
|
||||
This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission.
|
||||
</div>
|
||||
<div class="browser-page-text">
|
||||
<span class="browser-page-link" onclick="navigateToUrl('iana.org')">More information...</span>
|
||||
</div>
|
||||
]]
|
||||
},
|
||||
["google.com"] = {
|
||||
title = "Google",
|
||||
secure = true,
|
||||
content = [[
|
||||
<div style="text-align: center; padding: 60px 20px;">
|
||||
<div style="font-size: 48px; font-weight: 500; color: #4285F4;">G</div>
|
||||
<div style="font-size: 48px; font-weight: 500; color: #EA4335;">o</div>
|
||||
<div style="font-size: 48px; font-weight: 500; color: #FBBC05;">o</div>
|
||||
<div style="font-size: 48px; font-weight: 500; color: #4285F4;">g</div>
|
||||
<div style="font-size: 48px; font-weight: 500; color: #34A853;">l</div>
|
||||
<div style="font-size: 48px; font-weight: 500; color: #EA4335;">e</div>
|
||||
<div style="margin-top: 32px;">
|
||||
<input type="text" style="width: 80%; padding: 12px 20px; font-size: 16px; border-radius: 24px; background-color: #f1f3f4; border: none;" placeholder="Search Google"/>
|
||||
</div>
|
||||
</div>
|
||||
]]
|
||||
},
|
||||
["mosis.app"] = {
|
||||
title = "Mosis - Virtual Smartphone for VR",
|
||||
secure = true,
|
||||
content = [[
|
||||
<div class="browser-page-title">Welcome to Mosis</div>
|
||||
<div class="browser-page-text">
|
||||
Mosis is a virtual smartphone OS for VR games and applications. Experience a phone-like device inside your virtual reality environment.
|
||||
</div>
|
||||
<div style="margin-top: 24px;">
|
||||
<div class="browser-search-item" onclick="navigateToUrl('mosis.app/apps')">
|
||||
<div class="browser-search-title">Browse Apps</div>
|
||||
<div class="browser-search-desc">Discover apps for your virtual phone</div>
|
||||
</div>
|
||||
<div class="browser-search-item" onclick="navigateToUrl('mosis.app/developers')">
|
||||
<div class="browser-search-title">For Developers</div>
|
||||
<div class="browser-search-desc">Build apps for the Mosis platform</div>
|
||||
</div>
|
||||
</div>
|
||||
]]
|
||||
},
|
||||
["default"] = {
|
||||
title = "Page Not Found",
|
||||
secure = false,
|
||||
content = [[
|
||||
<div style="text-align: center; padding: 60px 20px;">
|
||||
<div style="font-size: 64px; color: #888888;">:(</div>
|
||||
<div style="font-size: 24px; color: #333333; margin-top: 24px;">Page Not Found</div>
|
||||
<div style="font-size: 16px; color: #666666; margin-top: 12px;">The requested page could not be loaded.</div>
|
||||
</div>
|
||||
]]
|
||||
}
|
||||
}
|
||||
|
||||
-- Initialize tabs
|
||||
local function initTabs()
|
||||
tabs = {
|
||||
{id = 1, url = "example.com", title = "Example Domain"}
|
||||
}
|
||||
current_tab_id = 1
|
||||
end
|
||||
|
||||
-- Initialize browser
|
||||
function initBrowser(doc)
|
||||
print("[Browser] Initializing...")
|
||||
browser_doc = doc
|
||||
initTabs()
|
||||
loadPage(tabs[1].url)
|
||||
end
|
||||
|
||||
-- Get current tab
|
||||
local function getCurrentTab()
|
||||
for _, tab in ipairs(tabs) do
|
||||
if tab.id == current_tab_id then
|
||||
return tab
|
||||
end
|
||||
end
|
||||
return tabs[1]
|
||||
end
|
||||
|
||||
-- Load a page
|
||||
function loadPage(url)
|
||||
if not browser_doc then return end
|
||||
|
||||
print("[Browser] Loading: " .. url)
|
||||
|
||||
-- Clean URL
|
||||
url = url:gsub("^https?://", ""):gsub("^www%.", ""):gsub("/$", "")
|
||||
|
||||
-- Update current tab
|
||||
local tab = getCurrentTab()
|
||||
if tab then
|
||||
tab.url = url
|
||||
end
|
||||
|
||||
-- Add to history
|
||||
table.insert(history, 1, {url = url, time = "Just now"})
|
||||
|
||||
-- Get page data
|
||||
local page = pages[url] or pages["default"]
|
||||
if tab then
|
||||
tab.title = page.title
|
||||
end
|
||||
|
||||
-- Update URL bar
|
||||
local url_input = browser_doc:GetElementById("url-input")
|
||||
if url_input then
|
||||
url_input.value = url
|
||||
end
|
||||
|
||||
-- Update secure icon
|
||||
local secure_icon = browser_doc:GetElementById("secure-icon")
|
||||
if secure_icon then
|
||||
if page.secure then
|
||||
secure_icon.inner_rml = "S"
|
||||
secure_icon.style.color = "#4CAF50"
|
||||
else
|
||||
secure_icon.inner_rml = "!"
|
||||
secure_icon.style.color = "#F44336"
|
||||
end
|
||||
end
|
||||
|
||||
-- Update page title
|
||||
local title = browser_doc:GetElementById("page-title")
|
||||
if title then
|
||||
title.inner_rml = page.title
|
||||
end
|
||||
|
||||
-- Update content
|
||||
local content = browser_doc:GetElementById("browser-content")
|
||||
if content then
|
||||
content.inner_rml = [[<div class="browser-page">]] .. page.content .. [[</div>]]
|
||||
end
|
||||
|
||||
-- Update tab count
|
||||
updateTabCount()
|
||||
end
|
||||
|
||||
-- Navigate to URL
|
||||
function navigateToUrl(url)
|
||||
loadPage(url)
|
||||
end
|
||||
|
||||
-- Handle URL input
|
||||
function onUrlSubmit()
|
||||
if not browser_doc then return end
|
||||
|
||||
local input = browser_doc:GetElementById("url-input")
|
||||
if input then
|
||||
local url = input.value or ""
|
||||
if url ~= "" then
|
||||
loadPage(url)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Go back in history
|
||||
function browserBack()
|
||||
if #history > 1 then
|
||||
table.remove(history, 1) -- Remove current page
|
||||
local prev = history[1]
|
||||
if prev then
|
||||
loadPage(prev.url)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Go forward (simplified - just reload)
|
||||
function browserForward()
|
||||
if showToast then
|
||||
showToast("No forward history")
|
||||
end
|
||||
end
|
||||
|
||||
-- Refresh page
|
||||
function browserRefresh()
|
||||
local tab = getCurrentTab()
|
||||
if tab then
|
||||
loadPage(tab.url)
|
||||
if showToast then
|
||||
showToast("Page refreshed")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Update tab count display
|
||||
function updateTabCount()
|
||||
if not browser_doc then return end
|
||||
|
||||
local count = browser_doc:GetElementById("tab-count")
|
||||
if count then
|
||||
count.inner_rml = tostring(#tabs)
|
||||
end
|
||||
end
|
||||
|
||||
-- Open new tab
|
||||
function newTab()
|
||||
local new_id = #tabs + 1
|
||||
table.insert(tabs, {
|
||||
id = new_id,
|
||||
url = "mosis.app",
|
||||
title = "New Tab"
|
||||
})
|
||||
current_tab_id = new_id
|
||||
loadPage("mosis.app")
|
||||
updateTabCount()
|
||||
print("[Browser] New tab opened: " .. new_id)
|
||||
end
|
||||
|
||||
-- Show tabs view
|
||||
function showTabs()
|
||||
print("[Browser] Show tabs")
|
||||
if showToast then
|
||||
showToast(#tabs .. " tab(s) open")
|
||||
end
|
||||
end
|
||||
|
||||
-- Close current tab
|
||||
function closeTab()
|
||||
if #tabs > 1 then
|
||||
for i, tab in ipairs(tabs) do
|
||||
if tab.id == current_tab_id then
|
||||
table.remove(tabs, i)
|
||||
break
|
||||
end
|
||||
end
|
||||
current_tab_id = tabs[1].id
|
||||
loadPage(tabs[1].url)
|
||||
updateTabCount()
|
||||
else
|
||||
if showToast then
|
||||
showToast("Cannot close last tab")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Add to bookmarks
|
||||
function addBookmark()
|
||||
local tab = getCurrentTab()
|
||||
if tab then
|
||||
table.insert(bookmarks, {
|
||||
url = tab.url,
|
||||
title = tab.title
|
||||
})
|
||||
if showToast then
|
||||
showToast("Bookmark added")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Show bookmarks
|
||||
function showBookmarks()
|
||||
print("[Browser] Show bookmarks")
|
||||
if showToast then
|
||||
showToast(#bookmarks .. " bookmark(s)")
|
||||
end
|
||||
end
|
||||
|
||||
-- Show history
|
||||
function showHistory()
|
||||
print("[Browser] Show history")
|
||||
if showToast then
|
||||
showToast(#history .. " items in history")
|
||||
end
|
||||
end
|
||||
|
||||
-- Share page
|
||||
function sharePage()
|
||||
local tab = getCurrentTab()
|
||||
if tab then
|
||||
print("[Browser] Share: " .. tab.url)
|
||||
if showToast then
|
||||
showToast("Share: " .. tab.url)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Show menu
|
||||
function showBrowserMenu()
|
||||
print("[Browser] Show menu")
|
||||
-- TODO: Show dropdown menu
|
||||
end
|
||||
|
||||
-- Go to home
|
||||
function browserHome()
|
||||
loadPage("mosis.app")
|
||||
end
|
||||
246
base-apps/com.mosis.browser/browser.rml
Normal file
246
base-apps/com.mosis.browser/browser.rml
Normal file
@@ -0,0 +1,246 @@
|
||||
<rml>
|
||||
<head>
|
||||
<link type="text/rcss" href="../../ui/html.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/theme.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/components.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/layout.rcss"/>
|
||||
<script src="../../scripts/navigation.lua"></script>
|
||||
<script src="../../scripts/layout.lua"></script>
|
||||
<script src="browser.lua"></script>
|
||||
<title>Browser</title>
|
||||
<style>
|
||||
.browser-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
background-color: #1E1E1E;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.browser-nav-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.browser-nav-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.browser-nav-btn:active {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.browser-nav-btn img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.browser-nav-btn.disabled {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.browser-url-bar {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
background-color: #2D2D2D;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.browser-url-bar:hover {
|
||||
background-color: #3D3D3D;
|
||||
}
|
||||
|
||||
.browser-secure-icon {
|
||||
font-size: 16px;
|
||||
color: #4CAF50;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.browser-url {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
color: #FFFFFF;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.browser-content {
|
||||
flex: 1;
|
||||
background-color: #FFFFFF;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.browser-page {
|
||||
padding: 16px;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.browser-page-title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #1a0dab;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.browser-page-text {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: #333333;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.browser-page-link {
|
||||
color: #1a0dab;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.browser-page-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.browser-search-item {
|
||||
margin-bottom: 20px;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.browser-search-item:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.browser-search-title {
|
||||
font-size: 18px;
|
||||
color: #1a0dab;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.browser-search-url {
|
||||
font-size: 14px;
|
||||
color: #006621;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.browser-search-desc {
|
||||
font-size: 16px;
|
||||
color: #545454;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.browser-bottom-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background-color: #1E1E1E;
|
||||
border-top: 1px solid #333333;
|
||||
}
|
||||
|
||||
.browser-tab-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
color: #B3B3B3;
|
||||
}
|
||||
|
||||
.browser-tab-btn:hover {
|
||||
color: #FFFFFF;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.browser-tab-btn img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-bottom: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.browser-tab-btn span {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.browser-tabs-indicator {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #B3B3B3;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: #B3B3B3;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="app-screen" onload="initLayout(document); initBrowser(document)" data-model="browser">
|
||||
<!-- System Status Bar -->
|
||||
<div class="system-status-bar">
|
||||
<span id="status-time" class="system-status-time">12:30</span>
|
||||
<div class="system-status-icons">
|
||||
<img src="../../icons/wifi.tga"/>
|
||||
<img src="../../icons/signal.tga"/>
|
||||
<img src="../../icons/battery.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Browser Toolbar -->
|
||||
<div class="browser-toolbar">
|
||||
<div class="browser-nav-btn" onclick="browserBack()">
|
||||
<img src="../../icons/back.tga"/>
|
||||
</div>
|
||||
<div class="browser-nav-btn" onclick="browserForward()">
|
||||
<img src="../../icons/forward.tga"/>
|
||||
</div>
|
||||
<div class="browser-url-bar">
|
||||
<span class="browser-secure-icon" id="secure-icon">S</span>
|
||||
<input class="browser-url" type="text" value="example.com" id="url-input" onchange="onUrlSubmit()"/>
|
||||
</div>
|
||||
<div class="browser-nav-btn" onclick="browserRefresh()">
|
||||
<img src="../../icons/refresh.tga"/>
|
||||
</div>
|
||||
<div class="browser-nav-btn" onclick="showBrowserMenu()">
|
||||
<img src="../../icons/more.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Browser Content -->
|
||||
<div class="browser-content" id="browser-content">
|
||||
<div class="browser-page">
|
||||
<div class="browser-page-title" id="page-title">Example Domain</div>
|
||||
<div class="browser-page-text">
|
||||
This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission.
|
||||
</div>
|
||||
<div class="browser-page-text">
|
||||
<span class="browser-page-link" onclick="navigateToUrl('iana.org')">More information...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Bar -->
|
||||
<div class="browser-bottom-bar">
|
||||
<div class="browser-tab-btn" onclick="browserHome()">
|
||||
<img src="../../icons/home.tga"/>
|
||||
<span>Home</span>
|
||||
</div>
|
||||
<div class="browser-tab-btn" onclick="showTabs()">
|
||||
<span class="browser-tabs-indicator" id="tab-count">1</span>
|
||||
<span>Tabs</span>
|
||||
</div>
|
||||
<div class="browser-tab-btn" onclick="newTab()">
|
||||
<img src="../../icons/add.tga"/>
|
||||
<span>New Tab</span>
|
||||
</div>
|
||||
<div class="browser-tab-btn" onclick="showBrowserMenu()">
|
||||
<img src="../../icons/menu.tga"/>
|
||||
<span>Menu</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</rml>
|
||||
18
base-apps/com.mosis.browser/manifest.json
Normal file
18
base-apps/com.mosis.browser/manifest.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "com.mosis.browser",
|
||||
"name": "Browser",
|
||||
"version": "1.0.0",
|
||||
"version_code": 1,
|
||||
"entry": "browser.rml",
|
||||
"icon": "../../icons/browser.tga",
|
||||
"description": "Web browser application",
|
||||
"developer": {
|
||||
"name": "Mosis Team",
|
||||
"email": "dev@mosis.dev"
|
||||
},
|
||||
"is_system_app": true,
|
||||
"permissions": [
|
||||
"network"
|
||||
],
|
||||
"min_api_version": 1
|
||||
}
|
||||
360
base-apps/com.mosis.camera/camera.lua
Normal file
360
base-apps/com.mosis.camera/camera.lua
Normal file
@@ -0,0 +1,360 @@
|
||||
-- camera.lua - Camera app functionality
|
||||
-- Handles capture, modes, flash, zoom, and camera switching
|
||||
|
||||
local camera_doc = nil
|
||||
local current_mode = "photo" -- photo, video, portrait, night
|
||||
local flash_mode = "auto" -- auto, on, off
|
||||
local timer_mode = "off" -- off, 3, 10
|
||||
local is_front_camera = false
|
||||
local is_recording = false
|
||||
local zoom_level = 1.0
|
||||
local photo_count = 0
|
||||
local video_duration = 0
|
||||
local video_timer_id = nil
|
||||
|
||||
-- Camera modes
|
||||
local modes = {"Night", "Portrait", "Photo", "Video", "More"}
|
||||
|
||||
-- Initialize camera
|
||||
function initCamera(doc)
|
||||
print("[Camera] Initializing...")
|
||||
camera_doc = doc
|
||||
updateModeDisplay()
|
||||
updateFlashDisplay()
|
||||
updateTimerDisplay()
|
||||
updateZoomDisplay()
|
||||
end
|
||||
|
||||
-- Update mode display
|
||||
function updateModeDisplay()
|
||||
if not camera_doc then return end
|
||||
|
||||
for _, mode in ipairs(modes) do
|
||||
local mode_el = camera_doc:GetElementById("mode-" .. mode:lower())
|
||||
if mode_el then
|
||||
if mode:lower() == current_mode then
|
||||
mode_el:SetClass("active", true)
|
||||
else
|
||||
mode_el:SetClass("active", false)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Update capture button appearance for video mode
|
||||
local capture_btn = camera_doc:GetElementById("capture-button")
|
||||
if capture_btn then
|
||||
if current_mode == "video" then
|
||||
if is_recording then
|
||||
capture_btn.inner_rml = [[<div class="capture-btn-stop"></div>]]
|
||||
else
|
||||
capture_btn.inner_rml = [[<div class="capture-btn-video"></div>]]
|
||||
end
|
||||
else
|
||||
capture_btn.inner_rml = [[<div class="capture-btn-inner"></div>]]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Switch camera mode
|
||||
function switchMode(mode)
|
||||
print("[Camera] Switching to mode: " .. mode)
|
||||
current_mode = mode:lower()
|
||||
|
||||
-- Stop recording if switching from video
|
||||
if is_recording then
|
||||
stopRecording()
|
||||
end
|
||||
|
||||
updateModeDisplay()
|
||||
|
||||
if showToast then
|
||||
showToast(mode .. " mode")
|
||||
end
|
||||
end
|
||||
|
||||
-- Toggle flash
|
||||
function toggleFlash()
|
||||
if flash_mode == "auto" then
|
||||
flash_mode = "on"
|
||||
elseif flash_mode == "on" then
|
||||
flash_mode = "off"
|
||||
else
|
||||
flash_mode = "auto"
|
||||
end
|
||||
|
||||
print("[Camera] Flash: " .. flash_mode)
|
||||
updateFlashDisplay()
|
||||
end
|
||||
|
||||
-- Update flash display
|
||||
function updateFlashDisplay()
|
||||
if not camera_doc then return end
|
||||
|
||||
local indicator = camera_doc:GetElementById("flash-indicator")
|
||||
if indicator then
|
||||
local text = "Flash: "
|
||||
if flash_mode == "auto" then
|
||||
text = text .. "Auto"
|
||||
elseif flash_mode == "on" then
|
||||
text = text .. "On"
|
||||
else
|
||||
text = text .. "Off"
|
||||
end
|
||||
indicator.inner_rml = text
|
||||
end
|
||||
end
|
||||
|
||||
-- Toggle timer
|
||||
function toggleTimer()
|
||||
if timer_mode == "off" then
|
||||
timer_mode = "3"
|
||||
elseif timer_mode == "3" then
|
||||
timer_mode = "10"
|
||||
else
|
||||
timer_mode = "off"
|
||||
end
|
||||
|
||||
print("[Camera] Timer: " .. timer_mode)
|
||||
updateTimerDisplay()
|
||||
end
|
||||
|
||||
-- Update timer display
|
||||
function updateTimerDisplay()
|
||||
if not camera_doc then return end
|
||||
|
||||
local indicator = camera_doc:GetElementById("timer-indicator")
|
||||
if indicator then
|
||||
local text = "Timer: "
|
||||
if timer_mode == "off" then
|
||||
text = text .. "Off"
|
||||
else
|
||||
text = text .. timer_mode .. "s"
|
||||
end
|
||||
indicator.inner_rml = text
|
||||
end
|
||||
end
|
||||
|
||||
-- Zoom in
|
||||
function zoomIn()
|
||||
if zoom_level < 10.0 then
|
||||
zoom_level = math.min(zoom_level + 0.5, 10.0)
|
||||
updateZoomDisplay()
|
||||
end
|
||||
end
|
||||
|
||||
-- Zoom out
|
||||
function zoomOut()
|
||||
if zoom_level > 0.5 then
|
||||
zoom_level = math.max(zoom_level - 0.5, 0.5)
|
||||
updateZoomDisplay()
|
||||
end
|
||||
end
|
||||
|
||||
-- Update zoom display
|
||||
function updateZoomDisplay()
|
||||
if not camera_doc then return end
|
||||
|
||||
local indicator = camera_doc:GetElementById("zoom-level")
|
||||
if indicator then
|
||||
indicator.inner_rml = string.format("%.1fx", zoom_level)
|
||||
end
|
||||
|
||||
print("[Camera] Zoom: " .. zoom_level)
|
||||
end
|
||||
|
||||
-- Switch camera (front/back)
|
||||
function switchCamera()
|
||||
is_front_camera = not is_front_camera
|
||||
print("[Camera] Switched to " .. (is_front_camera and "front" or "back") .. " camera")
|
||||
|
||||
if showToast then
|
||||
showToast(is_front_camera and "Front camera" or "Back camera")
|
||||
end
|
||||
|
||||
-- Update viewfinder placeholder
|
||||
local placeholder = camera_doc:GetElementById("viewfinder-placeholder")
|
||||
if placeholder then
|
||||
local icon = is_front_camera and "F" or "C"
|
||||
placeholder.inner_rml = [[
|
||||
<div class="viewfinder-placeholder-icon">]] .. icon .. [[</div>
|
||||
<div>]] .. (is_front_camera and "Front Camera" or "Camera Preview") .. [[</div>
|
||||
<div style="font-size: 14px; margin-top: 8px; color: #555555;">Tap to focus</div>
|
||||
]]
|
||||
end
|
||||
end
|
||||
|
||||
-- Capture photo or start/stop video
|
||||
function capture()
|
||||
if current_mode == "video" then
|
||||
if is_recording then
|
||||
stopRecording()
|
||||
else
|
||||
startRecording()
|
||||
end
|
||||
else
|
||||
takePhoto()
|
||||
end
|
||||
end
|
||||
|
||||
-- Take a photo
|
||||
function takePhoto()
|
||||
print("[Camera] Taking photo...")
|
||||
|
||||
-- Check timer
|
||||
if timer_mode ~= "off" then
|
||||
local delay = tonumber(timer_mode) * 1000
|
||||
if showToast then
|
||||
showToast("Timer: " .. timer_mode .. " seconds")
|
||||
end
|
||||
|
||||
if setTimeout then
|
||||
setTimeout(function()
|
||||
actuallyTakePhoto()
|
||||
end, delay)
|
||||
else
|
||||
actuallyTakePhoto()
|
||||
end
|
||||
else
|
||||
actuallyTakePhoto()
|
||||
end
|
||||
end
|
||||
|
||||
-- Actually capture the photo
|
||||
function actuallyTakePhoto()
|
||||
photo_count = photo_count + 1
|
||||
print("[Camera] Photo captured! Total: " .. photo_count)
|
||||
|
||||
-- Flash effect
|
||||
if flash_mode == "on" or (flash_mode == "auto" and not is_front_camera) then
|
||||
-- Simulate flash
|
||||
end
|
||||
|
||||
-- Show capture animation/feedback
|
||||
local viewfinder = camera_doc:GetElementById("camera-viewfinder")
|
||||
if viewfinder then
|
||||
viewfinder.style["background-color"] = "#FFFFFF"
|
||||
if setTimeout then
|
||||
setTimeout(function()
|
||||
viewfinder.style["background-color"] = "#1a1a1a"
|
||||
end, 100)
|
||||
end
|
||||
end
|
||||
|
||||
if showToast then
|
||||
showToast("Photo saved")
|
||||
end
|
||||
|
||||
-- Update gallery preview
|
||||
updateGalleryPreview()
|
||||
end
|
||||
|
||||
-- Start video recording
|
||||
function startRecording()
|
||||
print("[Camera] Starting recording...")
|
||||
is_recording = true
|
||||
video_duration = 0
|
||||
|
||||
updateModeDisplay()
|
||||
|
||||
-- Start timer
|
||||
if setInterval then
|
||||
video_timer_id = setInterval(function()
|
||||
video_duration = video_duration + 1
|
||||
updateRecordingTime()
|
||||
end, 1000)
|
||||
end
|
||||
|
||||
-- Show recording indicator
|
||||
local indicator = camera_doc:GetElementById("recording-indicator")
|
||||
if indicator then
|
||||
indicator.style.display = "flex"
|
||||
end
|
||||
end
|
||||
|
||||
-- Stop video recording
|
||||
function stopRecording()
|
||||
print("[Camera] Stopping recording...")
|
||||
is_recording = false
|
||||
|
||||
-- Stop timer
|
||||
if video_timer_id and clearInterval then
|
||||
clearInterval(video_timer_id)
|
||||
video_timer_id = nil
|
||||
end
|
||||
|
||||
updateModeDisplay()
|
||||
|
||||
-- Hide recording indicator
|
||||
local indicator = camera_doc:GetElementById("recording-indicator")
|
||||
if indicator then
|
||||
indicator.style.display = "none"
|
||||
end
|
||||
|
||||
if showToast then
|
||||
local minutes = math.floor(video_duration / 60)
|
||||
local seconds = video_duration % 60
|
||||
showToast(string.format("Video saved (%02d:%02d)", minutes, seconds))
|
||||
end
|
||||
|
||||
video_duration = 0
|
||||
end
|
||||
|
||||
-- Update recording time display
|
||||
function updateRecordingTime()
|
||||
if not camera_doc then return end
|
||||
|
||||
local time_el = camera_doc:GetElementById("recording-time")
|
||||
if time_el then
|
||||
local minutes = math.floor(video_duration / 60)
|
||||
local seconds = video_duration % 60
|
||||
time_el.inner_rml = string.format("%02d:%02d", minutes, seconds)
|
||||
end
|
||||
end
|
||||
|
||||
-- Update gallery preview
|
||||
function updateGalleryPreview()
|
||||
-- In a real app, this would show the last captured photo
|
||||
local preview = camera_doc:GetElementById("gallery-preview")
|
||||
if preview then
|
||||
preview.style["background-color"] = "#4CAF50"
|
||||
end
|
||||
end
|
||||
|
||||
-- Open gallery
|
||||
function openGallery()
|
||||
print("[Camera] Opening gallery...")
|
||||
if navigateTo then
|
||||
navigateTo("gallery")
|
||||
else
|
||||
if showToast then
|
||||
showToast("Gallery: " .. photo_count .. " photos")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Open camera settings
|
||||
function openCameraSettings()
|
||||
print("[Camera] Opening settings...")
|
||||
if showToast then
|
||||
showToast("Camera settings")
|
||||
end
|
||||
end
|
||||
|
||||
-- Handle tap to focus
|
||||
function onViewfinderTap(x, y)
|
||||
print("[Camera] Focus at: " .. x .. ", " .. y)
|
||||
|
||||
-- Move focus indicator
|
||||
local focus = camera_doc:GetElementById("focus-indicator")
|
||||
if focus then
|
||||
focus.style.left = x .. "px"
|
||||
focus.style.top = y .. "px"
|
||||
focus.style.opacity = "1"
|
||||
|
||||
if setTimeout then
|
||||
setTimeout(function()
|
||||
focus.style.opacity = "0.8"
|
||||
end, 500)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,15 +3,14 @@
|
||||
<link type="text/rcss" href="../../ui/html.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/theme.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/components.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/layout.rcss"/>
|
||||
<script src="../../scripts/navigation.lua"></script>
|
||||
<script src="../../scripts/layout.lua"></script>
|
||||
<script src="camera.lua"></script>
|
||||
<title>Camera</title>
|
||||
<style>
|
||||
.camera-screen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #000000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Top Controls */
|
||||
@@ -21,16 +20,16 @@
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
top: 36px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.camera-btn {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 28px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 24px;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -42,9 +41,14 @@
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.camera-btn:active {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.camera-btn img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Viewfinder */
|
||||
@@ -66,7 +70,6 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Placeholder for camera feed - replace with shared texture */
|
||||
.viewfinder-placeholder {
|
||||
color: #666666;
|
||||
font-size: 18px;
|
||||
@@ -136,10 +139,10 @@
|
||||
}
|
||||
|
||||
.camera-mode {
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
color: #B3B3B3;
|
||||
cursor: pointer;
|
||||
padding: 10px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.camera-mode.active {
|
||||
@@ -156,7 +159,7 @@
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding: 24px 32px;
|
||||
padding: 20px 32px;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
@@ -172,10 +175,15 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gallery-preview:hover {
|
||||
background-color: #444444;
|
||||
}
|
||||
|
||||
.gallery-preview img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.capture-btn {
|
||||
@@ -206,10 +214,17 @@
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
.capture-btn.video .capture-btn-inner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
.capture-btn-video {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
background-color: #F44336;
|
||||
}
|
||||
|
||||
.capture-btn-stop {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
background-color: #F44336;
|
||||
}
|
||||
|
||||
@@ -229,51 +244,51 @@
|
||||
}
|
||||
|
||||
.switch-camera-btn img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Settings Overlay */
|
||||
.settings-value {
|
||||
/* Indicators */
|
||||
.indicator {
|
||||
position: absolute;
|
||||
bottom: 200px;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
padding: 6px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.flash-indicator {
|
||||
top: 100px;
|
||||
left: 16px;
|
||||
}
|
||||
|
||||
.timer-indicator {
|
||||
top: 100px;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.recording-indicator {
|
||||
top: 140px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
padding: 10px 18px;
|
||||
border-radius: 22px;
|
||||
font-size: 18px;
|
||||
color: #FFFFFF;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Flash modes */
|
||||
.flash-indicator {
|
||||
position: absolute;
|
||||
top: 80px;
|
||||
left: 16px;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
padding: 8px 14px;
|
||||
border-radius: 14px;
|
||||
font-size: 16px;
|
||||
color: #FFFFFF;
|
||||
.recording-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
background-color: #F44336;
|
||||
}
|
||||
|
||||
/* Timer indicator */
|
||||
.timer-indicator {
|
||||
position: absolute;
|
||||
top: 80px;
|
||||
right: 16px;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
padding: 8px 14px;
|
||||
border-radius: 14px;
|
||||
font-size: 16px;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Zoom slider */
|
||||
/* Zoom control */
|
||||
.zoom-control {
|
||||
position: absolute;
|
||||
bottom: 180px;
|
||||
bottom: 200px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
@@ -282,20 +297,24 @@
|
||||
}
|
||||
|
||||
.zoom-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 22px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
font-size: 20px;
|
||||
color: #FFFFFF;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.zoom-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.zoom-level {
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
color: #FFD700;
|
||||
font-weight: 600;
|
||||
min-width: 48px;
|
||||
@@ -303,20 +322,30 @@
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="camera-screen">
|
||||
<body class="app-screen camera-screen" onload="initLayout(document); initCamera(document)">
|
||||
<!-- System Status Bar (transparent) -->
|
||||
<div class="system-status-bar" style="background-color: transparent; position: absolute; top: 0; left: 0; right: 0; z-index: 20;">
|
||||
<span id="status-time" class="system-status-time">12:30</span>
|
||||
<div class="system-status-icons">
|
||||
<img src="../../icons/wifi.tga"/>
|
||||
<img src="../../icons/signal.tga"/>
|
||||
<img src="../../icons/battery.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Bar -->
|
||||
<div class="camera-top-bar">
|
||||
<div class="camera-btn" onclick="goBack()">
|
||||
<img src="../../icons/close.tga"/>
|
||||
</div>
|
||||
<div style="display: flex; gap: 12px;">
|
||||
<div class="camera-btn">
|
||||
<div class="camera-btn" onclick="toggleFlash()">
|
||||
<img src="../../icons/flash.tga"/>
|
||||
</div>
|
||||
<div class="camera-btn">
|
||||
<div class="camera-btn" onclick="toggleTimer()">
|
||||
<img src="../../icons/timer.tga"/>
|
||||
</div>
|
||||
<div class="camera-btn">
|
||||
<div class="camera-btn" onclick="openCameraSettings()">
|
||||
<img src="../../icons/settings.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -325,11 +354,10 @@
|
||||
<!-- Viewfinder Area -->
|
||||
<div class="viewfinder-container">
|
||||
<div class="viewfinder" id="camera-viewfinder">
|
||||
<!-- This is where the shared camera texture would be rendered -->
|
||||
<div class="viewfinder-placeholder">
|
||||
<div class="viewfinder-placeholder" id="viewfinder-placeholder">
|
||||
<div class="viewfinder-placeholder-icon">C</div>
|
||||
<div>Camera Preview</div>
|
||||
<div style="font-size: 16px; margin-top: 8px; color: #555555;">
|
||||
<div style="font-size: 14px; margin-top: 8px; color: #555555;">
|
||||
Tap to focus
|
||||
</div>
|
||||
</div>
|
||||
@@ -343,41 +371,43 @@
|
||||
</div>
|
||||
|
||||
<!-- Focus Indicator -->
|
||||
<div class="focus-indicator"></div>
|
||||
<div class="focus-indicator" id="focus-indicator"></div>
|
||||
</div>
|
||||
|
||||
<!-- Flash Indicator -->
|
||||
<div class="flash-indicator">Flash: Auto</div>
|
||||
|
||||
<!-- Timer Indicator -->
|
||||
<div class="timer-indicator">Timer: Off</div>
|
||||
<!-- Indicators -->
|
||||
<div class="indicator flash-indicator" id="flash-indicator">Flash: Auto</div>
|
||||
<div class="indicator timer-indicator" id="timer-indicator">Timer: Off</div>
|
||||
<div class="indicator recording-indicator" id="recording-indicator">
|
||||
<div class="recording-dot"></div>
|
||||
<span id="recording-time">00:00</span>
|
||||
</div>
|
||||
|
||||
<!-- Zoom Control -->
|
||||
<div class="zoom-control">
|
||||
<div class="zoom-btn">-</div>
|
||||
<span class="zoom-level">1.0x</span>
|
||||
<div class="zoom-btn">+</div>
|
||||
<div class="zoom-btn" onclick="zoomOut()">-</div>
|
||||
<span class="zoom-level" id="zoom-level">1.0x</span>
|
||||
<div class="zoom-btn" onclick="zoomIn()">+</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Camera Modes -->
|
||||
<div class="camera-modes">
|
||||
<span class="camera-mode">Night</span>
|
||||
<span class="camera-mode">Portrait</span>
|
||||
<span class="camera-mode active">Photo</span>
|
||||
<span class="camera-mode">Video</span>
|
||||
<span class="camera-mode">More</span>
|
||||
<span id="mode-night" class="camera-mode" onclick="switchMode('Night')">Night</span>
|
||||
<span id="mode-portrait" class="camera-mode" onclick="switchMode('Portrait')">Portrait</span>
|
||||
<span id="mode-photo" class="camera-mode active" onclick="switchMode('Photo')">Photo</span>
|
||||
<span id="mode-video" class="camera-mode" onclick="switchMode('Video')">Video</span>
|
||||
<span id="mode-more" class="camera-mode" onclick="switchMode('More')">More</span>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Bar -->
|
||||
<div class="camera-bottom-bar">
|
||||
<div class="gallery-preview">
|
||||
<div class="gallery-preview" id="gallery-preview" onclick="openGallery()">
|
||||
<img src="../../icons/gallery.tga"/>
|
||||
</div>
|
||||
<div class="capture-btn" id="capture-button">
|
||||
<div class="capture-btn" id="capture-button" onclick="capture()">
|
||||
<div class="capture-btn-inner"></div>
|
||||
</div>
|
||||
<div class="switch-camera-btn">
|
||||
<div class="switch-camera-btn" onclick="switchCamera()">
|
||||
<img src="../../icons/switch-camera.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
19
base-apps/com.mosis.camera/manifest.json
Normal file
19
base-apps/com.mosis.camera/manifest.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"id": "com.mosis.camera",
|
||||
"name": "Camera",
|
||||
"version": "1.0.0",
|
||||
"version_code": 1,
|
||||
"entry": "camera.rml",
|
||||
"icon": "../../icons/camera.tga",
|
||||
"description": "Camera and photo capture",
|
||||
"developer": {
|
||||
"name": "Mosis Team",
|
||||
"email": "dev@mosis.dev"
|
||||
},
|
||||
"is_system_app": true,
|
||||
"permissions": [
|
||||
"camera",
|
||||
"storage"
|
||||
],
|
||||
"min_api_version": 1
|
||||
}
|
||||
374
base-apps/com.mosis.contacts/contacts.lua
Normal file
374
base-apps/com.mosis.contacts/contacts.lua
Normal file
@@ -0,0 +1,374 @@
|
||||
-- contacts.lua - Contacts management functionality
|
||||
-- Handles contact list, search, details, and actions
|
||||
|
||||
local contacts_doc = nil
|
||||
local contacts_data = {}
|
||||
local filtered_contacts = {}
|
||||
local search_query = ""
|
||||
local selected_contact = nil
|
||||
|
||||
-- Avatar colors for contacts
|
||||
local avatar_colors = {
|
||||
"#E91E63", "#9C27B0", "#673AB7", "#3F51B5", "#2196F3",
|
||||
"#03A9F4", "#00BCD4", "#009688", "#4CAF50", "#8BC34A",
|
||||
"#CDDC39", "#FFC107", "#FF9800", "#FF5722", "#795548"
|
||||
}
|
||||
|
||||
-- Get color for contact based on name
|
||||
local function getAvatarColor(name)
|
||||
local sum = 0
|
||||
for i = 1, #name do
|
||||
sum = sum + string.byte(name, i)
|
||||
end
|
||||
return avatar_colors[(sum % #avatar_colors) + 1]
|
||||
end
|
||||
|
||||
-- Initialize contacts data
|
||||
local function initContactsData()
|
||||
contacts_data = {
|
||||
{id = "1", name = "Alice Johnson", phone = "+1 555-0101", email = "alice@email.com", company = "Tech Corp"},
|
||||
{id = "2", name = "Andrew Smith", phone = "+1 555-0102", email = "andrew@email.com", company = "Design Studio"},
|
||||
{id = "3", name = "Bob Williams", phone = "+1 555-0201", email = "bob@email.com", company = ""},
|
||||
{id = "4", name = "Carol Davis", phone = "+1 555-0301", email = "carol.d@email.com", company = "Marketing Inc"},
|
||||
{id = "5", name = "Chris Miller", phone = "+1 555-0302", email = "", company = ""},
|
||||
{id = "6", name = "David Brown", phone = "+1 555-0401", email = "david.b@email.com", company = "Finance LLC"},
|
||||
{id = "7", name = "Emma Wilson", phone = "+1 555-0501", email = "emma@email.com", company = "Creative Agency"},
|
||||
{id = "8", name = "Frank Garcia", phone = "+1 555-0601", email = "", company = ""},
|
||||
{id = "9", name = "Grace Lee", phone = "+1 555-0701", email = "grace.lee@email.com", company = "Healthcare Plus"},
|
||||
{id = "10", name = "Henry Taylor", phone = "+1 555-0801", email = "henry@email.com", company = ""},
|
||||
{id = "11", name = "Isabella Martinez", phone = "+1 555-0901", email = "isabella@email.com", company = "Education Center"},
|
||||
{id = "12", name = "John Doe", phone = "+1 555-1234", email = "john.doe@email.com", company = "Software Inc"},
|
||||
{id = "13", name = "Kate Thompson", phone = "+1 555-1101", email = "", company = "Legal Partners"},
|
||||
{id = "14", name = "Liam Anderson", phone = "+1 555-1201", email = "liam.a@email.com", company = ""},
|
||||
{id = "15", name = "Mary Taylor", phone = "+1 555-0601", email = "mary@email.com", company = "Consulting Group"},
|
||||
{id = "16", name = "Michael Lee", phone = "+1 555-0602", email = "michael.l@email.com", company = ""},
|
||||
{id = "17", name = "Noah White", phone = "+1 555-1401", email = "noah@email.com", company = "Real Estate Co"},
|
||||
{id = "18", name = "Olivia Harris", phone = "+1 555-1501", email = "", company = ""},
|
||||
{id = "19", name = "Peter Clark", phone = "+1 555-1601", email = "peter.c@email.com", company = "Manufacturing Ltd"},
|
||||
{id = "20", name = "Sarah Anderson", phone = "+1 555-0701", email = "sarah@email.com", company = "Media Group"},
|
||||
}
|
||||
|
||||
-- Sort by name
|
||||
table.sort(contacts_data, function(a, b)
|
||||
return a.name:lower() < b.name:lower()
|
||||
end)
|
||||
|
||||
filtered_contacts = contacts_data
|
||||
end
|
||||
|
||||
-- Initialize contacts
|
||||
function initContacts(doc)
|
||||
print("[Contacts] Initializing...")
|
||||
contacts_doc = doc
|
||||
initContactsData()
|
||||
renderContacts()
|
||||
end
|
||||
|
||||
-- Render contacts list grouped by first letter
|
||||
function renderContacts()
|
||||
if not contacts_doc then return end
|
||||
|
||||
local container = contacts_doc:GetElementById("contacts-list")
|
||||
if not container then return end
|
||||
|
||||
local html = ""
|
||||
local current_letter = ""
|
||||
|
||||
for _, contact in ipairs(filtered_contacts) do
|
||||
local first_letter = contact.name:sub(1, 1):upper()
|
||||
|
||||
-- Add letter header if new letter
|
||||
if first_letter ~= current_letter then
|
||||
current_letter = first_letter
|
||||
html = html .. [[
|
||||
<div class="contact-letter">]] .. first_letter .. [[</div>
|
||||
]]
|
||||
end
|
||||
|
||||
-- Get avatar color and initial
|
||||
local color = getAvatarColor(contact.name)
|
||||
local initial = contact.name:sub(1, 1):upper()
|
||||
|
||||
html = html .. [[
|
||||
<div class="contact-item" onclick="selectContact(']] .. contact.id .. [[')">
|
||||
<div class="contact-avatar" style="background-color: ]] .. color .. [[;">]] .. initial .. [[</div>
|
||||
<div class="contact-info">
|
||||
<div class="contact-name">]] .. contact.name .. [[</div>
|
||||
<div class="contact-phone">]] .. contact.phone .. [[</div>
|
||||
</div>
|
||||
<div class="contact-call-btn" onclick="callContact(']] .. contact.id .. [['); event.stopPropagation();">
|
||||
<img src="../../icons/phone.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
]]
|
||||
end
|
||||
|
||||
if #filtered_contacts == 0 then
|
||||
html = [[
|
||||
<div style="text-align: center; padding: 40px; color: #888888;">
|
||||
<div style="font-size: 18px;">No contacts found</div>
|
||||
</div>
|
||||
]]
|
||||
end
|
||||
|
||||
container.inner_rml = html
|
||||
end
|
||||
|
||||
-- Search contacts
|
||||
function searchContacts(query)
|
||||
print("[Contacts] Searching: " .. query)
|
||||
search_query = query:lower()
|
||||
|
||||
if search_query == "" then
|
||||
filtered_contacts = contacts_data
|
||||
else
|
||||
filtered_contacts = {}
|
||||
for _, contact in ipairs(contacts_data) do
|
||||
if contact.name:lower():find(search_query, 1, true) or
|
||||
contact.phone:find(search_query, 1, true) or
|
||||
(contact.email and contact.email:lower():find(search_query, 1, true)) then
|
||||
table.insert(filtered_contacts, contact)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
renderContacts()
|
||||
end
|
||||
|
||||
-- Handle search input
|
||||
function onSearchInput(element)
|
||||
local query = element.value or ""
|
||||
searchContacts(query)
|
||||
end
|
||||
|
||||
-- Select a contact to view details
|
||||
function selectContact(contact_id)
|
||||
print("[Contacts] Selected contact: " .. contact_id)
|
||||
|
||||
-- Find contact by ID
|
||||
for _, contact in ipairs(contacts_data) do
|
||||
if contact.id == contact_id then
|
||||
selected_contact = contact
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if selected_contact then
|
||||
showContactDetail()
|
||||
end
|
||||
end
|
||||
|
||||
-- Show contact detail view
|
||||
function showContactDetail()
|
||||
if not selected_contact or not contacts_doc then return end
|
||||
|
||||
-- Store contact info for detail screen
|
||||
if mosis and mosis.state then
|
||||
mosis.state.set("selected_contact", selected_contact)
|
||||
end
|
||||
|
||||
-- Navigate to detail screen
|
||||
if navigateTo then
|
||||
navigateTo("contact_detail")
|
||||
else
|
||||
-- Show inline detail
|
||||
showContactDetailInline()
|
||||
end
|
||||
end
|
||||
|
||||
-- Show contact detail inline (if navigation not available)
|
||||
function showContactDetailInline()
|
||||
if not contacts_doc then return end
|
||||
|
||||
local detail = contacts_doc:GetElementById("contact-detail")
|
||||
local list = contacts_doc:GetElementById("contacts-list-container")
|
||||
|
||||
if detail and list then
|
||||
list.style.display = "none"
|
||||
detail.style.display = "flex"
|
||||
renderContactDetail()
|
||||
end
|
||||
end
|
||||
|
||||
-- Render contact detail
|
||||
function renderContactDetail()
|
||||
if not selected_contact or not contacts_doc then return end
|
||||
|
||||
local color = getAvatarColor(selected_contact.name)
|
||||
local initial = selected_contact.name:sub(1, 1):upper()
|
||||
|
||||
local detail_avatar = contacts_doc:GetElementById("detail-avatar")
|
||||
local detail_name = contacts_doc:GetElementById("detail-name")
|
||||
local detail_info = contacts_doc:GetElementById("detail-info")
|
||||
|
||||
if detail_avatar then
|
||||
detail_avatar.style["background-color"] = color
|
||||
detail_avatar.inner_rml = initial
|
||||
end
|
||||
|
||||
if detail_name then
|
||||
detail_name.inner_rml = selected_contact.name
|
||||
end
|
||||
|
||||
if detail_info then
|
||||
local html = ""
|
||||
|
||||
-- Phone
|
||||
html = html .. [[
|
||||
<div class="detail-row" onclick="callContact(']] .. selected_contact.id .. [[')">
|
||||
<div class="detail-icon"><img src="../../icons/phone.tga" style="width: 24px; height: 24px;"/></div>
|
||||
<div class="detail-content">
|
||||
<div class="detail-label">Phone</div>
|
||||
<div class="detail-value">]] .. selected_contact.phone .. [[</div>
|
||||
</div>
|
||||
<div class="detail-action"><img src="../../icons/call_small.tga" style="width: 24px; height: 24px;"/></div>
|
||||
</div>
|
||||
]]
|
||||
|
||||
-- Email
|
||||
if selected_contact.email and selected_contact.email ~= "" then
|
||||
html = html .. [[
|
||||
<div class="detail-row">
|
||||
<div class="detail-icon"><img src="../../icons/email.tga" style="width: 24px; height: 24px;"/></div>
|
||||
<div class="detail-content">
|
||||
<div class="detail-label">Email</div>
|
||||
<div class="detail-value">]] .. selected_contact.email .. [[</div>
|
||||
</div>
|
||||
</div>
|
||||
]]
|
||||
end
|
||||
|
||||
-- Company
|
||||
if selected_contact.company and selected_contact.company ~= "" then
|
||||
html = html .. [[
|
||||
<div class="detail-row">
|
||||
<div class="detail-icon"><img src="../../icons/work.tga" style="width: 24px; height: 24px;"/></div>
|
||||
<div class="detail-content">
|
||||
<div class="detail-label">Company</div>
|
||||
<div class="detail-value">]] .. selected_contact.company .. [[</div>
|
||||
</div>
|
||||
</div>
|
||||
]]
|
||||
end
|
||||
|
||||
detail_info.inner_rml = html
|
||||
end
|
||||
end
|
||||
|
||||
-- Hide contact detail
|
||||
function hideContactDetail()
|
||||
if not contacts_doc then return end
|
||||
|
||||
local detail = contacts_doc:GetElementById("contact-detail")
|
||||
local list = contacts_doc:GetElementById("contacts-list-container")
|
||||
|
||||
if detail and list then
|
||||
detail.style.display = "none"
|
||||
list.style.display = "flex"
|
||||
end
|
||||
|
||||
selected_contact = nil
|
||||
end
|
||||
|
||||
-- Call a contact
|
||||
function callContact(contact_id)
|
||||
print("[Contacts] Calling contact: " .. contact_id)
|
||||
|
||||
local contact = nil
|
||||
for _, c in ipairs(contacts_data) do
|
||||
if c.id == contact_id then
|
||||
contact = c
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if contact then
|
||||
-- Store call info
|
||||
if mosis and mosis.state then
|
||||
mosis.state.set("current_call", {
|
||||
number = contact.phone,
|
||||
name = contact.name
|
||||
})
|
||||
end
|
||||
|
||||
-- Navigate to calling screen
|
||||
if navigateTo then
|
||||
navigateTo("calling")
|
||||
else
|
||||
if showToast then
|
||||
showToast("Calling " .. contact.name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Message a contact
|
||||
function messageContact(contact_id)
|
||||
print("[Contacts] Messaging contact: " .. contact_id)
|
||||
|
||||
local contact = nil
|
||||
for _, c in ipairs(contacts_data) do
|
||||
if c.id == contact_id then
|
||||
contact = c
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if contact then
|
||||
if mosis and mosis.state then
|
||||
mosis.state.set("chat_contact", {
|
||||
name = contact.name,
|
||||
phone = contact.phone
|
||||
})
|
||||
end
|
||||
|
||||
if navigateTo then
|
||||
navigateTo("chat")
|
||||
else
|
||||
if showToast then
|
||||
showToast("Message " .. contact.name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Add new contact
|
||||
function addContact()
|
||||
print("[Contacts] Add new contact")
|
||||
if navigateTo then
|
||||
navigateTo("add_contact")
|
||||
else
|
||||
if showToast then
|
||||
showToast("Add contact")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Edit contact
|
||||
function editContact(contact_id)
|
||||
print("[Contacts] Edit contact: " .. contact_id)
|
||||
if showToast then
|
||||
showToast("Edit contact")
|
||||
end
|
||||
end
|
||||
|
||||
-- Delete contact
|
||||
function deleteContact(contact_id)
|
||||
print("[Contacts] Delete contact: " .. contact_id)
|
||||
|
||||
-- Find and remove contact
|
||||
for i, c in ipairs(contacts_data) do
|
||||
if c.id == contact_id then
|
||||
table.remove(contacts_data, i)
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
-- Re-filter and render
|
||||
searchContacts(search_query)
|
||||
hideContactDetail()
|
||||
|
||||
if showToast then
|
||||
showToast("Contact deleted")
|
||||
end
|
||||
end
|
||||
352
base-apps/com.mosis.contacts/contacts.rml
Normal file
352
base-apps/com.mosis.contacts/contacts.rml
Normal file
@@ -0,0 +1,352 @@
|
||||
<rml>
|
||||
<head>
|
||||
<link type="text/rcss" href="../../ui/html.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/theme.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/components.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/layout.rcss"/>
|
||||
<script src="../../scripts/navigation.lua"></script>
|
||||
<script src="../../scripts/layout.lua"></script>
|
||||
<script src="contacts.lua"></script>
|
||||
<title>Contacts</title>
|
||||
<style>
|
||||
.contacts-list {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.contact-letter {
|
||||
padding: 8px 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: #BB86FC;
|
||||
background-color: #1E1E1E;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.contact-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.contact-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.contact-item:active {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.contact-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 24px;
|
||||
margin-right: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.contact-name {
|
||||
font-size: 18px;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.contact-phone {
|
||||
font-size: 16px;
|
||||
color: #B3B3B3;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.contact-call-btn {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
||||
.contact-call-btn:hover {
|
||||
background-color: rgba(76, 175, 80, 0.2);
|
||||
}
|
||||
|
||||
.contact-call-btn:active {
|
||||
background-color: rgba(76, 175, 80, 0.3);
|
||||
}
|
||||
|
||||
.contact-call-btn img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Contact Detail View */
|
||||
#contact-detail {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
background-color: #1E1E1E;
|
||||
}
|
||||
|
||||
.detail-avatar {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 48px;
|
||||
margin: 0 auto 16px auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 40px;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 32px;
|
||||
padding: 20px;
|
||||
background-color: #1E1E1E;
|
||||
border-bottom: 1px solid #333333;
|
||||
}
|
||||
|
||||
.detail-action-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.detail-action-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.detail-action-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 24px;
|
||||
background-color: #BB86FC;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.detail-action-icon img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.detail-action-label {
|
||||
font-size: 14px;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.detail-info {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #333333;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.detail-row:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.detail-icon {
|
||||
width: 40px;
|
||||
margin-right: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 14px;
|
||||
color: #888888;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 18px;
|
||||
color: #FFFFFF;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.detail-action {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* Phone app bottom tabs */
|
||||
.phone-tabs {
|
||||
height: 72px;
|
||||
background-color: #1E1E1E;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.phone-tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.phone-tab:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.phone-tab.active {
|
||||
color: #BB86FC;
|
||||
}
|
||||
|
||||
.phone-tab img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.phone-tab span {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Search style adjustments */
|
||||
.search-input {
|
||||
flex: 1;
|
||||
background-color: transparent;
|
||||
font-size: 18px;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="app-screen" onload="initLayout(document); initContacts(document)" data-model="contacts">
|
||||
<!-- System Status Bar -->
|
||||
<div class="system-status-bar">
|
||||
<span id="status-time" class="system-status-time">12:30</span>
|
||||
<div class="system-status-icons">
|
||||
<img src="../../icons/wifi.tga"/>
|
||||
<img src="../../icons/signal.tga"/>
|
||||
<img src="../../icons/battery.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App Bar -->
|
||||
<div class="app-bar">
|
||||
<div class="app-bar-back" onclick="goBack()">
|
||||
<img src="../../icons/back.tga"/>
|
||||
</div>
|
||||
<span class="app-bar-title">Contacts</span>
|
||||
<div class="app-bar-actions">
|
||||
<div class="app-bar-action" onclick="addContact()">
|
||||
<img src="../../icons/add.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contacts List Container -->
|
||||
<div id="contacts-list-container" class="app-content" style="display: flex; flex-direction: column;">
|
||||
<!-- Search Bar -->
|
||||
<div class="search-bar">
|
||||
<img src="../../icons/search.tga" class="search-icon" style="width: 24px; height: 24px;"/>
|
||||
<input class="search-input" type="text" placeholder="Search contacts" onchange="onSearchInput(this)"/>
|
||||
</div>
|
||||
|
||||
<!-- Contacts List -->
|
||||
<div class="contacts-list" id="contacts-list">
|
||||
<!-- Populated by Lua -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Detail View -->
|
||||
<div id="contact-detail">
|
||||
<div class="detail-header">
|
||||
<div class="detail-avatar" id="detail-avatar">A</div>
|
||||
<div class="detail-name" id="detail-name">Contact Name</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-actions">
|
||||
<div class="detail-action-btn" onclick="callContact(selected_contact and selected_contact.id or '')">
|
||||
<div class="detail-action-icon">
|
||||
<img src="../../icons/call_small.tga"/>
|
||||
</div>
|
||||
<span class="detail-action-label">Call</span>
|
||||
</div>
|
||||
<div class="detail-action-btn" onclick="messageContact(selected_contact and selected_contact.id or '')">
|
||||
<div class="detail-action-icon" style="background-color: #03DAC6;">
|
||||
<img src="../../icons/message.tga"/>
|
||||
</div>
|
||||
<span class="detail-action-label">Message</span>
|
||||
</div>
|
||||
<div class="detail-action-btn" onclick="editContact(selected_contact and selected_contact.id or '')">
|
||||
<div class="detail-action-icon" style="background-color: #FF9800;">
|
||||
<img src="../../icons/edit.tga"/>
|
||||
</div>
|
||||
<span class="detail-action-label">Edit</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-info" id="detail-info">
|
||||
<!-- Populated by Lua -->
|
||||
</div>
|
||||
|
||||
<div style="padding: 16px;">
|
||||
<div class="btn btn-outlined" style="width: 100%; text-align: center;" onclick="hideContactDetail()">
|
||||
Back to Contacts
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FAB -->
|
||||
<div class="btn-fab" onclick="addContact()">
|
||||
<img src="../../icons/add.tga" style="width: 32px; height: 32px;"/>
|
||||
</div>
|
||||
|
||||
<!-- Phone App Bottom Tabs -->
|
||||
<div class="phone-tabs">
|
||||
<div class="phone-tab" onclick="navigateTo('dialer')">
|
||||
<img src="../../icons/dialpad.tga"/>
|
||||
<span>Keypad</span>
|
||||
</div>
|
||||
<div class="phone-tab" onclick="switchTab('recent')">
|
||||
<img src="../../icons/history.tga"/>
|
||||
<span>Recent</span>
|
||||
</div>
|
||||
<div class="phone-tab active">
|
||||
<img src="../../icons/contacts.tga"/>
|
||||
<span>Contacts</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</rml>
|
||||
18
base-apps/com.mosis.contacts/manifest.json
Normal file
18
base-apps/com.mosis.contacts/manifest.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "com.mosis.contacts",
|
||||
"name": "Contacts",
|
||||
"version": "1.0.0",
|
||||
"version_code": 1,
|
||||
"entry": "contacts.rml",
|
||||
"icon": "../../icons/contacts.tga",
|
||||
"description": "Contact list and management",
|
||||
"developer": {
|
||||
"name": "Mosis Team",
|
||||
"email": "dev@mosis.dev"
|
||||
},
|
||||
"is_system_app": true,
|
||||
"permissions": [
|
||||
"contacts"
|
||||
],
|
||||
"min_api_version": 1
|
||||
}
|
||||
227
base-apps/com.mosis.dialer/calling.lua
Normal file
227
base-apps/com.mosis.dialer/calling.lua
Normal file
@@ -0,0 +1,227 @@
|
||||
-- calling.lua - In-call screen functionality
|
||||
-- Handles call state, duration timer, and call controls
|
||||
|
||||
local calling_doc = nil
|
||||
local call_state = "connecting" -- connecting, active, ended
|
||||
local call_start_time = 0
|
||||
local call_duration = 0
|
||||
local timer_id = nil
|
||||
local is_muted = false
|
||||
local is_speaker = false
|
||||
local is_on_hold = false
|
||||
|
||||
-- Call info
|
||||
local call_number = ""
|
||||
local call_name = ""
|
||||
|
||||
-- Initialize calling screen
|
||||
function initCalling(doc)
|
||||
print("[Calling] Initializing...")
|
||||
calling_doc = doc
|
||||
|
||||
-- Get call info from state or use defaults
|
||||
if mosis and mosis.state then
|
||||
local call_info = mosis.state.get("current_call")
|
||||
if call_info then
|
||||
call_number = call_info.number or ""
|
||||
call_name = call_info.name or call_number
|
||||
end
|
||||
end
|
||||
|
||||
-- Fallback test data
|
||||
if call_number == "" then
|
||||
call_number = "+1 555-0101"
|
||||
call_name = "Alice Johnson"
|
||||
end
|
||||
|
||||
-- Update UI
|
||||
updateCallInfo()
|
||||
|
||||
-- Simulate connection after delay
|
||||
if setTimeout then
|
||||
setTimeout(function()
|
||||
setCallState("active")
|
||||
end, 2000)
|
||||
else
|
||||
setCallState("active")
|
||||
end
|
||||
end
|
||||
|
||||
-- Update call info display
|
||||
function updateCallInfo()
|
||||
if not calling_doc then return end
|
||||
|
||||
local name_el = calling_doc:GetElementById("call-name")
|
||||
local number_el = calling_doc:GetElementById("call-number")
|
||||
local status_el = calling_doc:GetElementById("call-status")
|
||||
local avatar_el = calling_doc:GetElementById("call-avatar")
|
||||
|
||||
if name_el then
|
||||
name_el.inner_rml = call_name
|
||||
end
|
||||
|
||||
if number_el then
|
||||
number_el.inner_rml = call_number
|
||||
end
|
||||
|
||||
if avatar_el then
|
||||
-- Get first letter for avatar
|
||||
local initial = call_name:sub(1, 1):upper()
|
||||
avatar_el.inner_rml = initial
|
||||
end
|
||||
end
|
||||
|
||||
-- Set call state
|
||||
function setCallState(state)
|
||||
print("[Calling] State changed to: " .. state)
|
||||
call_state = state
|
||||
|
||||
local status_el = calling_doc:GetElementById("call-status")
|
||||
local timer_el = calling_doc:GetElementById("call-timer")
|
||||
|
||||
if state == "connecting" then
|
||||
if status_el then
|
||||
status_el.inner_rml = "Calling..."
|
||||
end
|
||||
if timer_el then
|
||||
timer_el.style.display = "none"
|
||||
end
|
||||
elseif state == "active" then
|
||||
if status_el then
|
||||
status_el.inner_rml = "Connected"
|
||||
end
|
||||
if timer_el then
|
||||
timer_el.style.display = "block"
|
||||
end
|
||||
-- Start duration timer
|
||||
startCallTimer()
|
||||
elseif state == "ended" then
|
||||
if status_el then
|
||||
status_el.inner_rml = "Call ended"
|
||||
end
|
||||
stopCallTimer()
|
||||
-- Return to dialer after delay
|
||||
if setTimeout then
|
||||
setTimeout(function()
|
||||
if goBack then
|
||||
goBack()
|
||||
end
|
||||
end, 1500)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Start call duration timer
|
||||
function startCallTimer()
|
||||
call_start_time = os.time and os.time() or 0
|
||||
call_duration = 0
|
||||
|
||||
if setInterval then
|
||||
timer_id = setInterval(function()
|
||||
call_duration = call_duration + 1
|
||||
updateTimerDisplay()
|
||||
end, 1000)
|
||||
end
|
||||
end
|
||||
|
||||
-- Stop call timer
|
||||
function stopCallTimer()
|
||||
if timer_id and clearInterval then
|
||||
clearInterval(timer_id)
|
||||
timer_id = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- Update timer display
|
||||
function updateTimerDisplay()
|
||||
local timer_el = calling_doc:GetElementById("call-timer")
|
||||
if timer_el then
|
||||
local minutes = math.floor(call_duration / 60)
|
||||
local seconds = call_duration % 60
|
||||
timer_el.inner_rml = string.format("%02d:%02d", minutes, seconds)
|
||||
end
|
||||
end
|
||||
|
||||
-- Toggle mute
|
||||
function toggleMute()
|
||||
is_muted = not is_muted
|
||||
print("[Calling] Mute: " .. tostring(is_muted))
|
||||
|
||||
local mute_btn = calling_doc:GetElementById("btn-mute")
|
||||
if mute_btn then
|
||||
if is_muted then
|
||||
mute_btn:SetClass("active", true)
|
||||
else
|
||||
mute_btn:SetClass("active", false)
|
||||
end
|
||||
end
|
||||
|
||||
if showToast then
|
||||
showToast(is_muted and "Muted" or "Unmuted")
|
||||
end
|
||||
end
|
||||
|
||||
-- Toggle speaker
|
||||
function toggleSpeaker()
|
||||
is_speaker = not is_speaker
|
||||
print("[Calling] Speaker: " .. tostring(is_speaker))
|
||||
|
||||
local speaker_btn = calling_doc:GetElementById("btn-speaker")
|
||||
if speaker_btn then
|
||||
if is_speaker then
|
||||
speaker_btn:SetClass("active", true)
|
||||
else
|
||||
speaker_btn:SetClass("active", false)
|
||||
end
|
||||
end
|
||||
|
||||
if showToast then
|
||||
showToast(is_speaker and "Speaker on" or "Speaker off")
|
||||
end
|
||||
end
|
||||
|
||||
-- Toggle hold
|
||||
function toggleHold()
|
||||
is_on_hold = not is_on_hold
|
||||
print("[Calling] Hold: " .. tostring(is_on_hold))
|
||||
|
||||
local hold_btn = calling_doc:GetElementById("btn-hold")
|
||||
if hold_btn then
|
||||
if is_on_hold then
|
||||
hold_btn:SetClass("active", true)
|
||||
else
|
||||
hold_btn:SetClass("active", false)
|
||||
end
|
||||
end
|
||||
|
||||
local status_el = calling_doc:GetElementById("call-status")
|
||||
if status_el then
|
||||
if is_on_hold then
|
||||
status_el.inner_rml = "On hold"
|
||||
else
|
||||
status_el.inner_rml = "Connected"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Show dialpad
|
||||
function showDialpad()
|
||||
print("[Calling] Show dialpad")
|
||||
if showToast then
|
||||
showToast("Dialpad")
|
||||
end
|
||||
end
|
||||
|
||||
-- Add call (conference)
|
||||
function addCall()
|
||||
print("[Calling] Add call")
|
||||
if showToast then
|
||||
showToast("Add call")
|
||||
end
|
||||
end
|
||||
|
||||
-- End call
|
||||
function endCall()
|
||||
print("[Calling] Ending call")
|
||||
setCallState("ended")
|
||||
end
|
||||
191
base-apps/com.mosis.dialer/calling.rml
Normal file
191
base-apps/com.mosis.dialer/calling.rml
Normal file
@@ -0,0 +1,191 @@
|
||||
<rml>
|
||||
<head>
|
||||
<link type="text/rcss" href="../../ui/html.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/theme.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/components.rcss"/>
|
||||
<script src="../../scripts/navigation.lua"></script>
|
||||
<script src="calling.lua"></script>
|
||||
<title>Calling</title>
|
||||
<style>
|
||||
.calling-screen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, #1a237e 0%, #000000 100%);
|
||||
background-color: #1a237e;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.calling-header {
|
||||
padding-top: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.call-avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 60px;
|
||||
background-color: #BB86FC;
|
||||
margin: 0 auto 24px auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 48px;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.call-name {
|
||||
font-size: 32px;
|
||||
font-weight: 500;
|
||||
color: #FFFFFF;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.call-number {
|
||||
font-size: 18px;
|
||||
color: #B3B3B3;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.call-status {
|
||||
font-size: 18px;
|
||||
color: #4CAF50;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.call-timer {
|
||||
font-size: 24px;
|
||||
color: #FFFFFF;
|
||||
font-weight: 300;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.calling-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.call-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
gap: 32px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.call-control-btn {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 36px;
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.call-control-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.call-control-btn.active {
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
.call-control-btn img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.call-control-label {
|
||||
font-size: 12px;
|
||||
color: #FFFFFF;
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.call-control-btn.active .call-control-label {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.end-call-container {
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.end-call-btn {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 40px;
|
||||
background-color: #F44336;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.end-call-btn:hover {
|
||||
background-color: #E53935;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.end-call-btn:active {
|
||||
background-color: #C62828;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.end-call-btn img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="calling-screen" onload="initCalling(document)">
|
||||
<!-- Caller Info -->
|
||||
<div class="calling-header">
|
||||
<div class="call-avatar" id="call-avatar">A</div>
|
||||
<div class="call-name" id="call-name">Alice Johnson</div>
|
||||
<div class="call-number" id="call-number">+1 555-0101</div>
|
||||
<div class="call-status" id="call-status">Calling...</div>
|
||||
<div class="call-timer" id="call-timer">00:00</div>
|
||||
</div>
|
||||
|
||||
<!-- Spacer -->
|
||||
<div class="calling-content"></div>
|
||||
|
||||
<!-- Call Controls -->
|
||||
<div class="call-controls">
|
||||
<div id="btn-mute" class="call-control-btn" onclick="toggleMute()">
|
||||
<img src="../../icons/mic_off.tga"/>
|
||||
<span class="call-control-label">Mute</span>
|
||||
</div>
|
||||
<div id="btn-keypad" class="call-control-btn" onclick="showDialpad()">
|
||||
<img src="../../icons/dialpad.tga"/>
|
||||
<span class="call-control-label">Keypad</span>
|
||||
</div>
|
||||
<div id="btn-speaker" class="call-control-btn" onclick="toggleSpeaker()">
|
||||
<img src="../../icons/volume.tga"/>
|
||||
<span class="call-control-label">Speaker</span>
|
||||
</div>
|
||||
<div id="btn-add" class="call-control-btn" onclick="addCall()">
|
||||
<img src="../../icons/add.tga"/>
|
||||
<span class="call-control-label">Add call</span>
|
||||
</div>
|
||||
<div id="btn-hold" class="call-control-btn" onclick="toggleHold()">
|
||||
<img src="../../icons/pause.tga"/>
|
||||
<span class="call-control-label">Hold</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- End Call Button -->
|
||||
<div class="end-call-container">
|
||||
<div class="end-call-btn" onclick="endCall()">
|
||||
<img src="../../icons/call_end.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</rml>
|
||||
235
base-apps/com.mosis.dialer/dialer.lua
Normal file
235
base-apps/com.mosis.dialer/dialer.lua
Normal file
@@ -0,0 +1,235 @@
|
||||
-- dialer.lua - Phone dialer functionality
|
||||
-- Handles dial pad input, call management, and call history
|
||||
|
||||
local dialer_doc = nil
|
||||
local dial_number = ""
|
||||
local call_history = {}
|
||||
local current_tab = "keypad" -- keypad, recent, contacts
|
||||
|
||||
-- Sample call history data
|
||||
local function initCallHistory()
|
||||
call_history = {
|
||||
{name = "Alice Johnson", number = "+1 555-0101", type = "incoming", time = "2:34 PM", duration = "5:23"},
|
||||
{name = "Bob Williams", number = "+1 555-0201", type = "outgoing", time = "1:15 PM", duration = "2:45"},
|
||||
{name = "Carol Davis", number = "+1 555-0301", type = "missed", time = "Yesterday", duration = nil},
|
||||
{name = "David Brown", number = "+1 555-0401", type = "incoming", time = "Yesterday", duration = "12:30"},
|
||||
{name = "Emma Wilson", number = "+1 555-0501", type = "outgoing", time = "Mon", duration = "3:15"},
|
||||
{name = "+1 555-9999", number = "+1 555-9999", type = "missed", time = "Mon", duration = nil},
|
||||
{name = "John Doe", number = "+1 555-1234", type = "incoming", time = "Sun", duration = "8:42"},
|
||||
}
|
||||
end
|
||||
|
||||
-- Initialize dialer
|
||||
function initDialer(doc)
|
||||
print("[Dialer] Initializing...")
|
||||
dialer_doc = doc
|
||||
dial_number = ""
|
||||
initCallHistory()
|
||||
updateDialDisplay()
|
||||
end
|
||||
|
||||
-- Update the dial display
|
||||
function updateDialDisplay()
|
||||
if not dialer_doc then return end
|
||||
|
||||
local display = dialer_doc:GetElementById("dial-display")
|
||||
if display then
|
||||
if dial_number == "" then
|
||||
display.inner_rml = '<span style="color: #666666;">Enter number</span>'
|
||||
else
|
||||
-- Format number for display
|
||||
local formatted = formatPhoneNumber(dial_number)
|
||||
display.inner_rml = formatted
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Format phone number for display
|
||||
function formatPhoneNumber(number)
|
||||
local len = #number
|
||||
if len <= 3 then
|
||||
return number
|
||||
elseif len <= 6 then
|
||||
return number:sub(1,3) .. "-" .. number:sub(4)
|
||||
elseif len <= 10 then
|
||||
return "(" .. number:sub(1,3) .. ") " .. number:sub(4,6) .. "-" .. number:sub(7)
|
||||
else
|
||||
return "+1 (" .. number:sub(1,3) .. ") " .. number:sub(4,6) .. "-" .. number:sub(7,10)
|
||||
end
|
||||
end
|
||||
|
||||
-- Handle dial key press
|
||||
function dial_press(key)
|
||||
print("[Dialer] Key pressed: " .. key)
|
||||
|
||||
if #dial_number < 15 then
|
||||
dial_number = dial_number .. key
|
||||
updateDialDisplay()
|
||||
|
||||
-- Play haptic/sound feedback if available
|
||||
if mosis and mosis.haptic then
|
||||
mosis.haptic.vibrate(10)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Handle backspace
|
||||
function dial_backspace()
|
||||
if #dial_number > 0 then
|
||||
dial_number = dial_number:sub(1, -2)
|
||||
updateDialDisplay()
|
||||
end
|
||||
end
|
||||
|
||||
-- Clear dial number
|
||||
function dial_clear()
|
||||
dial_number = ""
|
||||
updateDialDisplay()
|
||||
end
|
||||
|
||||
-- Make a call
|
||||
function make_call()
|
||||
if dial_number == "" then
|
||||
print("[Dialer] Cannot call: no number entered")
|
||||
if showToast then
|
||||
showToast("Enter a number to call")
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
print("[Dialer] Calling: " .. dial_number)
|
||||
|
||||
-- Add to call history
|
||||
table.insert(call_history, 1, {
|
||||
name = dial_number,
|
||||
number = dial_number,
|
||||
type = "outgoing",
|
||||
time = "Just now",
|
||||
duration = nil
|
||||
})
|
||||
|
||||
-- Navigate to calling screen
|
||||
if navigateTo then
|
||||
-- Store call info for the calling screen
|
||||
if mosis and mosis.state then
|
||||
mosis.state.set("current_call", {
|
||||
number = dial_number,
|
||||
name = getContactName(dial_number),
|
||||
start_time = os.time and os.time() or 0
|
||||
})
|
||||
end
|
||||
navigateTo("calling")
|
||||
else
|
||||
-- Fallback: load calling screen directly
|
||||
local calling_path = dialer_doc:GetSourceURL():gsub("dialer.rml", "calling.rml")
|
||||
if mosis and mosis.loadDocument then
|
||||
mosis.loadDocument(calling_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Get contact name by number (returns number if not found)
|
||||
function getContactName(number)
|
||||
for _, call in ipairs(call_history) do
|
||||
if call.number == number and call.name ~= number then
|
||||
return call.name
|
||||
end
|
||||
end
|
||||
return number
|
||||
end
|
||||
|
||||
-- Switch tabs
|
||||
function switchTab(tab_name)
|
||||
print("[Dialer] Switching to tab: " .. tab_name)
|
||||
current_tab = tab_name
|
||||
|
||||
-- Update tab UI
|
||||
local tabs = {"keypad", "recent", "contacts"}
|
||||
for _, tab in ipairs(tabs) do
|
||||
local tab_el = dialer_doc:GetElementById("tab-" .. tab)
|
||||
if tab_el then
|
||||
if tab == tab_name then
|
||||
tab_el:SetClass("active", true)
|
||||
else
|
||||
tab_el:SetClass("active", false)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Show/hide content
|
||||
local keypad_content = dialer_doc:GetElementById("keypad-content")
|
||||
local recent_content = dialer_doc:GetElementById("recent-content")
|
||||
|
||||
if keypad_content then
|
||||
keypad_content.style.display = (tab_name == "keypad") and "flex" or "none"
|
||||
end
|
||||
if recent_content then
|
||||
recent_content.style.display = (tab_name == "recent") and "block" or "none"
|
||||
end
|
||||
|
||||
-- Render recent calls if switching to that tab
|
||||
if tab_name == "recent" then
|
||||
renderCallHistory()
|
||||
end
|
||||
end
|
||||
|
||||
-- Render call history
|
||||
function renderCallHistory()
|
||||
local container = dialer_doc:GetElementById("recent-list")
|
||||
if not container then return end
|
||||
|
||||
local html = ""
|
||||
for _, call in ipairs(call_history) do
|
||||
local icon_color = "#4CAF50" -- incoming = green
|
||||
local icon = "phone.tga"
|
||||
|
||||
if call.type == "outgoing" then
|
||||
icon_color = "#2196F3" -- blue
|
||||
icon = "call_made.tga"
|
||||
elseif call.type == "missed" then
|
||||
icon_color = "#F44336" -- red
|
||||
icon = "call_missed.tga"
|
||||
end
|
||||
|
||||
local duration_text = call.duration or "Missed"
|
||||
|
||||
html = html .. [[
|
||||
<div class="call-history-item" onclick="callNumber(']] .. call.number .. [[')">
|
||||
<div class="call-history-icon" style="background-color: ]] .. icon_color .. [[;">
|
||||
<img src="../../icons/]] .. icon .. [[" style="width: 24px; height: 24px;"/>
|
||||
</div>
|
||||
<div class="call-history-info">
|
||||
<div class="call-history-name">]] .. call.name .. [[</div>
|
||||
<div class="call-history-meta">]] .. call.type .. " - " .. call.time .. [[</div>
|
||||
</div>
|
||||
<div class="call-history-time">]] .. duration_text .. [[</div>
|
||||
</div>
|
||||
]]
|
||||
end
|
||||
|
||||
container.inner_rml = html
|
||||
end
|
||||
|
||||
-- Call a number from history
|
||||
function callNumber(number)
|
||||
dial_number = number:gsub("[^%d+]", "") -- Remove non-digit chars except +
|
||||
updateDialDisplay()
|
||||
switchTab("keypad")
|
||||
make_call()
|
||||
end
|
||||
|
||||
-- Long press on 0 for +
|
||||
function dial_long_press_zero()
|
||||
if dial_number == "" or dial_number:sub(-1) ~= "0" then
|
||||
dial_press("+")
|
||||
else
|
||||
-- Replace last 0 with +
|
||||
dial_number = dial_number:sub(1, -2) .. "+"
|
||||
updateDialDisplay()
|
||||
end
|
||||
end
|
||||
|
||||
-- Long press on * for pause
|
||||
function dial_long_press_star()
|
||||
dial_press(",") -- Comma is standard pause character
|
||||
end
|
||||
225
base-apps/com.mosis.dialer/dialer.rml
Normal file
225
base-apps/com.mosis.dialer/dialer.rml
Normal file
@@ -0,0 +1,225 @@
|
||||
<rml>
|
||||
<head>
|
||||
<link type="text/rcss" href="../../ui/html.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/theme.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/components.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/layout.rcss"/>
|
||||
<script src="../../scripts/navigation.lua"></script>
|
||||
<script src="../../scripts/layout.lua"></script>
|
||||
<script src="dialer.lua"></script>
|
||||
<title>Phone</title>
|
||||
<style>
|
||||
.dialer-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Keypad content */
|
||||
#keypad-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Recent calls content */
|
||||
#recent-content {
|
||||
flex: 1;
|
||||
display: none;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.call-history-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.call-history-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.call-history-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
margin-right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.call-history-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.call-history-name {
|
||||
font-size: 16px;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.call-history-meta {
|
||||
font-size: 14px;
|
||||
color: #888888;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.call-history-time {
|
||||
font-size: 14px;
|
||||
color: #888888;
|
||||
}
|
||||
|
||||
/* Phone app bottom tabs */
|
||||
.phone-tabs {
|
||||
height: 72px;
|
||||
background-color: #1E1E1E;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.phone-tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.phone-tab:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.phone-tab.active {
|
||||
color: #BB86FC;
|
||||
}
|
||||
|
||||
.phone-tab img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.phone-tab span {
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="app-screen" onload="initLayout(document); initDialer(document)">
|
||||
<!-- System Status Bar -->
|
||||
<div class="system-status-bar">
|
||||
<span id="status-time" class="system-status-time">12:30</span>
|
||||
<div class="system-status-icons">
|
||||
<img src="../../icons/wifi.tga"/>
|
||||
<img src="../../icons/signal.tga"/>
|
||||
<img src="../../icons/battery.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App Bar -->
|
||||
<div class="app-bar">
|
||||
<div class="app-bar-back" onclick="goBack()">
|
||||
<img src="../../icons/back.tga"/>
|
||||
</div>
|
||||
<span class="app-bar-title">Phone</span>
|
||||
</div>
|
||||
|
||||
<!-- Dialer Content -->
|
||||
<div class="dialer-content">
|
||||
<!-- Keypad View -->
|
||||
<div id="keypad-content">
|
||||
<!-- Dial Display -->
|
||||
<div class="dial-display" id="dial-display">
|
||||
<span style="color: #666666;">Enter number</span>
|
||||
</div>
|
||||
|
||||
<!-- Dial Pad -->
|
||||
<div class="dial-pad">
|
||||
<div class="dial-key" onclick="dial_press('1')">
|
||||
<span class="dial-key-number">1</span>
|
||||
<span class="dial-key-letters"></span>
|
||||
</div>
|
||||
<div class="dial-key" onclick="dial_press('2')">
|
||||
<span class="dial-key-number">2</span>
|
||||
<span class="dial-key-letters">ABC</span>
|
||||
</div>
|
||||
<div class="dial-key" onclick="dial_press('3')">
|
||||
<span class="dial-key-number">3</span>
|
||||
<span class="dial-key-letters">DEF</span>
|
||||
</div>
|
||||
<div class="dial-key" onclick="dial_press('4')">
|
||||
<span class="dial-key-number">4</span>
|
||||
<span class="dial-key-letters">GHI</span>
|
||||
</div>
|
||||
<div class="dial-key" onclick="dial_press('5')">
|
||||
<span class="dial-key-number">5</span>
|
||||
<span class="dial-key-letters">JKL</span>
|
||||
</div>
|
||||
<div class="dial-key" onclick="dial_press('6')">
|
||||
<span class="dial-key-number">6</span>
|
||||
<span class="dial-key-letters">MNO</span>
|
||||
</div>
|
||||
<div class="dial-key" onclick="dial_press('7')">
|
||||
<span class="dial-key-number">7</span>
|
||||
<span class="dial-key-letters">PQRS</span>
|
||||
</div>
|
||||
<div class="dial-key" onclick="dial_press('8')">
|
||||
<span class="dial-key-number">8</span>
|
||||
<span class="dial-key-letters">TUV</span>
|
||||
</div>
|
||||
<div class="dial-key" onclick="dial_press('9')">
|
||||
<span class="dial-key-number">9</span>
|
||||
<span class="dial-key-letters">WXYZ</span>
|
||||
</div>
|
||||
<div class="dial-key" onclick="dial_press('*')">
|
||||
<span class="dial-key-number">*</span>
|
||||
<span class="dial-key-letters"></span>
|
||||
</div>
|
||||
<div class="dial-key" onclick="dial_press('0')">
|
||||
<span class="dial-key-number">0</span>
|
||||
<span class="dial-key-letters">+</span>
|
||||
</div>
|
||||
<div class="dial-key" onclick="dial_press('#')">
|
||||
<span class="dial-key-number">#</span>
|
||||
<span class="dial-key-letters"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Call Actions -->
|
||||
<div class="dial-actions">
|
||||
<div style="width: 56px;"></div>
|
||||
<div class="dial-call-btn" onclick="make_call()">
|
||||
<img src="../../icons/call_small.tga" style="width: 32px; height: 32px; pointer-events: none;"/>
|
||||
</div>
|
||||
<div class="btn-icon" onclick="dial_backspace()" style="width: 56px; height: 56px;">
|
||||
<img src="../../icons/backspace.tga" style="width: 32px; height: 32px; pointer-events: none;"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Calls View -->
|
||||
<div id="recent-content">
|
||||
<div id="recent-list">
|
||||
<!-- Populated by Lua -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phone App Bottom Tabs -->
|
||||
<div class="phone-tabs">
|
||||
<div id="tab-keypad" class="phone-tab active" onclick="switchTab('keypad')">
|
||||
<img src="../../icons/dialpad.tga"/>
|
||||
<span>Keypad</span>
|
||||
</div>
|
||||
<div id="tab-recent" class="phone-tab" onclick="switchTab('recent')">
|
||||
<img src="../../icons/history.tga"/>
|
||||
<span>Recent</span>
|
||||
</div>
|
||||
<div id="tab-contacts" class="phone-tab" onclick="navigateTo('contacts')">
|
||||
<img src="../../icons/contacts.tga"/>
|
||||
<span>Contacts</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</rml>
|
||||
18
base-apps/com.mosis.dialer/manifest.json
Normal file
18
base-apps/com.mosis.dialer/manifest.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "com.mosis.dialer",
|
||||
"name": "Phone",
|
||||
"version": "1.0.0",
|
||||
"version_code": 1,
|
||||
"entry": "dialer.rml",
|
||||
"icon": "../../icons/phone.tga",
|
||||
"description": "Phone dialer and call interface",
|
||||
"developer": {
|
||||
"name": "Mosis Team",
|
||||
"email": "dev@mosis.dev"
|
||||
},
|
||||
"is_system_app": true,
|
||||
"permissions": [
|
||||
"phone"
|
||||
],
|
||||
"min_api_version": 1
|
||||
}
|
||||
200
base-apps/com.mosis.home/home.lua
Normal file
200
base-apps/com.mosis.home/home.lua
Normal file
@@ -0,0 +1,200 @@
|
||||
-- home.lua - Home screen dynamic app rendering
|
||||
-- Handles system apps and discovered third-party apps
|
||||
|
||||
-- System apps with their navigation keys and colors
|
||||
local system_apps = {
|
||||
-- Row 1
|
||||
{name = "Phone", icon = "phone", color = "#4CAF50", nav = "dialer"},
|
||||
{name = "Messages", icon = "message", color = "#2196F3", nav = "messages"},
|
||||
{name = "Contacts", icon = "contacts", color = "#FF9800", nav = "contacts"},
|
||||
{name = "Browser", icon = "browser", color = "#F44336", nav = "browser"},
|
||||
-- Row 2
|
||||
{name = "Gallery", icon = "gallery", color = "#9C27B0", nav = nil},
|
||||
{name = "Camera", icon = "camera", color = "#00BCD4", nav = "camera"},
|
||||
{name = "Settings", icon = "settings", color = "#607D8B", nav = "settings"},
|
||||
{name = "Music", icon = "music", color = "#E91E63", nav = "music"},
|
||||
-- Row 3
|
||||
{name = "Calendar", icon = "calendar", color = "#3F51B5", nav = nil},
|
||||
{name = "Clock", icon = "clock", color = "#009688", nav = nil},
|
||||
{name = "Notes", icon = "notes", color = "#795548", nav = nil},
|
||||
{name = "Maps", icon = "maps", color = "#FF5722", nav = nil},
|
||||
-- Row 4
|
||||
{name = "Store", icon = "store", color = "#8BC34A", nav = "store"},
|
||||
{name = "Files", icon = "files", color = "#CDDC39", nav = nil},
|
||||
{name = "Calculator", icon = "calculator", color = "#FFC107", nav = nil},
|
||||
{name = "Weather", icon = "weather", color = "#673AB7", nav = nil},
|
||||
}
|
||||
|
||||
-- State
|
||||
local installed_apps = {}
|
||||
local home_document = nil -- Store document reference
|
||||
|
||||
-- Initialize on load (receives document from onload event)
|
||||
function initHome(doc)
|
||||
print("[Home] Initializing home screen...")
|
||||
home_document = doc
|
||||
|
||||
-- Get installed third-party apps
|
||||
if mosis and mosis.apps then
|
||||
installed_apps = mosis.apps.getInstalled() or {}
|
||||
print("[Home] Found " .. #installed_apps .. " installed apps")
|
||||
|
||||
-- Filter to only third-party (non-system) apps
|
||||
local third_party = {}
|
||||
for _, app in ipairs(installed_apps) do
|
||||
if not app.is_system_app then
|
||||
table.insert(third_party, app)
|
||||
print("[Home] Third-party app: " .. app.name .. " (" .. app.package_id .. ")")
|
||||
end
|
||||
end
|
||||
installed_apps = third_party
|
||||
else
|
||||
print("[Home] Warning: mosis.apps API not available")
|
||||
installed_apps = {}
|
||||
end
|
||||
|
||||
-- Render dynamic apps
|
||||
renderThirdPartyApps()
|
||||
end
|
||||
|
||||
-- Generate a color based on package_id
|
||||
function getAppColor(package_id)
|
||||
local colors = {
|
||||
"#BB86FC", "#03DAC6", "#FF9800", "#2196F3",
|
||||
"#4CAF50", "#F44336", "#E91E63", "#3F51B5",
|
||||
"#009688", "#795548", "#FF5722", "#673AB7"
|
||||
}
|
||||
|
||||
-- Simple hash of package_id to pick a color
|
||||
local hash = 0
|
||||
for i = 1, #package_id do
|
||||
hash = hash + package_id:byte(i)
|
||||
end
|
||||
|
||||
return colors[(hash % #colors) + 1]
|
||||
end
|
||||
|
||||
-- Get first letter for placeholder icon
|
||||
function getAppInitial(name)
|
||||
return name:sub(1, 1):upper()
|
||||
end
|
||||
|
||||
-- Render third-party apps into the grid
|
||||
function renderThirdPartyApps()
|
||||
-- Use stored document reference
|
||||
if not home_document then
|
||||
print("[Home] Could not get document reference")
|
||||
return
|
||||
end
|
||||
|
||||
local grid = home_document:GetElementById("third-party-apps")
|
||||
if not grid then
|
||||
print("[Home] third-party-apps container not found")
|
||||
return
|
||||
end
|
||||
|
||||
-- Clear existing content
|
||||
grid.inner_rml = ""
|
||||
|
||||
if #installed_apps == 0 then
|
||||
print("[Home] No third-party apps to display")
|
||||
return
|
||||
end
|
||||
|
||||
-- Build HTML for each app
|
||||
local html = ""
|
||||
for _, app in ipairs(installed_apps) do
|
||||
local color = getAppColor(app.package_id)
|
||||
local initial = getAppInitial(app.name)
|
||||
local icon_html
|
||||
|
||||
-- Check if app has an icon
|
||||
if app.icon and app.icon ~= "" then
|
||||
local icon_path
|
||||
-- Check if icon is already a full path (starts with / or contains :/)
|
||||
if app.icon:sub(1, 1) == "/" or app.icon:find(":/") then
|
||||
-- Already a full path
|
||||
icon_path = app.icon
|
||||
elseif app.install_path and app.install_path ~= "" then
|
||||
-- Relative filename - construct full path from install_path
|
||||
icon_path = app.install_path .. "/" .. app.icon
|
||||
else
|
||||
icon_path = app.icon
|
||||
end
|
||||
-- Use file:// prefix for absolute paths to prevent RmlUi URL resolution
|
||||
local src_path = icon_path
|
||||
if icon_path:sub(1, 1) == "/" then
|
||||
src_path = "file://" .. icon_path
|
||||
end
|
||||
-- Use img tag for actual icon
|
||||
icon_html = '<img src="' .. src_path .. '" style="width: 48px; height: 48px;"/>'
|
||||
print("[Home] Loading icon: " .. src_path)
|
||||
else
|
||||
-- Fallback to initial letter
|
||||
icon_html = '<span style="font-size: 28px; color: #000000;">' .. initial .. '</span>'
|
||||
end
|
||||
|
||||
html = html .. [[
|
||||
<div class="app-icon">
|
||||
<div class="app-icon-image" style="background-color: ]] .. color .. [[;"
|
||||
onclick="launchThirdPartyApp(']] .. app.package_id .. [[')">
|
||||
]] .. icon_html .. [[
|
||||
</div>
|
||||
<span class="app-icon-label">]] .. app.name .. [[</span>
|
||||
</div>
|
||||
]]
|
||||
end
|
||||
|
||||
grid.inner_rml = html
|
||||
print("[Home] Rendered " .. #installed_apps .. " third-party apps")
|
||||
end
|
||||
|
||||
-- Get app info by package_id
|
||||
function getAppInfo(package_id)
|
||||
for _, app in ipairs(installed_apps) do
|
||||
if app.package_id == package_id then
|
||||
return app
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Launch a third-party app
|
||||
function launchThirdPartyApp(package_id)
|
||||
print("[Home] Launching app: " .. package_id)
|
||||
|
||||
if mosis and mosis.apps then
|
||||
local success = mosis.apps.launch(package_id)
|
||||
if success then
|
||||
print("[Home] App sandbox started: " .. package_id)
|
||||
|
||||
-- Get app info for sandbox switching and UI loading
|
||||
local app_info = getAppInfo(package_id)
|
||||
if app_info and app_info.install_path and app_info.entry_point then
|
||||
-- Switch sandbox context to this app (registers timer, fs, json, crypto APIs)
|
||||
if switchAppSandbox then
|
||||
switchAppSandbox(package_id, app_info.install_path)
|
||||
print("[Home] Sandbox context switched to: " .. package_id)
|
||||
end
|
||||
|
||||
-- Now load the app's UI document
|
||||
local entry_path = app_info.install_path .. "/" .. app_info.entry_point
|
||||
print("[Home] Loading app screen: " .. entry_path)
|
||||
local loaded = loadScreen(entry_path)
|
||||
if loaded then
|
||||
print("[Home] App UI loaded: " .. package_id)
|
||||
else
|
||||
print("[Home] Failed to load app UI: " .. entry_path)
|
||||
end
|
||||
else
|
||||
print("[Home] App info missing entry point: " .. package_id)
|
||||
end
|
||||
else
|
||||
print("[Home] Failed to launch app: " .. package_id)
|
||||
end
|
||||
else
|
||||
print("[Home] Cannot launch app: mosis.apps not available")
|
||||
end
|
||||
end
|
||||
|
||||
-- initHome() is called via onload in home.rml
|
||||
@@ -43,30 +43,31 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Third-party apps use same sizing as system apps */
|
||||
#third-party-apps .app-icon {
|
||||
width: 25%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#third-party-apps .app-icon-image {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 8px auto;
|
||||
margin: 0 auto 12px auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#third-party-apps .app-icon-image:hover {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
#third-party-apps .app-icon-label {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-size: 16px;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
</style>
|
||||
16
base-apps/com.mosis.home/manifest.json
Normal file
16
base-apps/com.mosis.home/manifest.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "com.mosis.home",
|
||||
"name": "Home",
|
||||
"version": "1.0.0",
|
||||
"version_code": 1,
|
||||
"entry": "home.rml",
|
||||
"icon": "../../icons/home.tga",
|
||||
"description": "Mosis home screen and app launcher",
|
||||
"developer": {
|
||||
"name": "Mosis Team",
|
||||
"email": "dev@mosis.dev"
|
||||
},
|
||||
"is_system_app": true,
|
||||
"permissions": [],
|
||||
"min_api_version": 1
|
||||
}
|
||||
18
base-apps/com.mosis.messages/manifest.json
Normal file
18
base-apps/com.mosis.messages/manifest.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "com.mosis.messages",
|
||||
"name": "Messages",
|
||||
"version": "1.0.0",
|
||||
"version_code": 1,
|
||||
"entry": "messages.rml",
|
||||
"icon": "../../icons/message.tga",
|
||||
"description": "SMS and messaging application",
|
||||
"developer": {
|
||||
"name": "Mosis Team",
|
||||
"email": "dev@mosis.dev"
|
||||
},
|
||||
"is_system_app": true,
|
||||
"permissions": [
|
||||
"sms"
|
||||
],
|
||||
"min_api_version": 1
|
||||
}
|
||||
398
base-apps/com.mosis.messages/messages.lua
Normal file
398
base-apps/com.mosis.messages/messages.lua
Normal file
@@ -0,0 +1,398 @@
|
||||
-- messages.lua - Messages app functionality
|
||||
-- Handles conversation list and individual chats
|
||||
|
||||
local messages_doc = nil
|
||||
local conversations = {}
|
||||
local current_conversation = nil
|
||||
local current_messages = {}
|
||||
|
||||
-- Avatar colors
|
||||
local avatar_colors = {
|
||||
"#E91E63", "#9C27B0", "#673AB7", "#3F51B5", "#2196F3",
|
||||
"#03A9F4", "#00BCD4", "#009688", "#4CAF50", "#FF9800"
|
||||
}
|
||||
|
||||
local function getAvatarColor(name)
|
||||
local sum = 0
|
||||
for i = 1, #name do
|
||||
sum = sum + string.byte(name, i)
|
||||
end
|
||||
return avatar_colors[(sum % #avatar_colors) + 1]
|
||||
end
|
||||
|
||||
-- Initialize conversations data
|
||||
local function initConversationsData()
|
||||
conversations = {
|
||||
{
|
||||
id = "1",
|
||||
name = "Alice Johnson",
|
||||
phone = "+1 555-0101",
|
||||
last_message = "Hey! Are you coming to the party tonight?",
|
||||
time = "2:34 PM",
|
||||
unread = 2,
|
||||
messages = {
|
||||
{sender = "them", text = "Hey!", time = "2:30 PM"},
|
||||
{sender = "them", text = "What are you up to?", time = "2:31 PM"},
|
||||
{sender = "me", text = "Not much, just working", time = "2:32 PM"},
|
||||
{sender = "them", text = "Cool! There's a party at Mike's tonight", time = "2:33 PM"},
|
||||
{sender = "them", text = "Hey! Are you coming to the party tonight?", time = "2:34 PM"},
|
||||
}
|
||||
},
|
||||
{
|
||||
id = "2",
|
||||
name = "Bob Williams",
|
||||
phone = "+1 555-0201",
|
||||
last_message = "Thanks for the help yesterday!",
|
||||
time = "1:15 PM",
|
||||
unread = 0,
|
||||
messages = {
|
||||
{sender = "them", text = "Hey, can you help me with something?", time = "Yesterday"},
|
||||
{sender = "me", text = "Sure, what do you need?", time = "Yesterday"},
|
||||
{sender = "them", text = "I need help moving some furniture", time = "Yesterday"},
|
||||
{sender = "me", text = "No problem, I'll be there at 2", time = "Yesterday"},
|
||||
{sender = "them", text = "Thanks for the help yesterday!", time = "1:15 PM"},
|
||||
}
|
||||
},
|
||||
{
|
||||
id = "3",
|
||||
name = "Carol Davis",
|
||||
phone = "+1 555-0301",
|
||||
last_message = "The meeting has been rescheduled to Friday",
|
||||
time = "Yesterday",
|
||||
unread = 0,
|
||||
messages = {
|
||||
{sender = "them", text = "Hi, are you free for a meeting tomorrow?", time = "Monday"},
|
||||
{sender = "me", text = "Let me check my calendar", time = "Monday"},
|
||||
{sender = "me", text = "Yes, I'm free at 3pm", time = "Monday"},
|
||||
{sender = "them", text = "The meeting has been rescheduled to Friday", time = "Yesterday"},
|
||||
}
|
||||
},
|
||||
{
|
||||
id = "4",
|
||||
name = "David Brown",
|
||||
phone = "+1 555-0401",
|
||||
last_message = "Can you send me the files?",
|
||||
time = "Yesterday",
|
||||
unread = 1,
|
||||
messages = {
|
||||
{sender = "them", text = "Hey, do you have the project files?", time = "Yesterday"},
|
||||
{sender = "me", text = "Which ones?", time = "Yesterday"},
|
||||
{sender = "them", text = "Can you send me the files?", time = "Yesterday"},
|
||||
}
|
||||
},
|
||||
{
|
||||
id = "5",
|
||||
name = "Emma Wilson",
|
||||
phone = "+1 555-0501",
|
||||
last_message = "See you at the coffee shop!",
|
||||
time = "Mon",
|
||||
unread = 0,
|
||||
messages = {
|
||||
{sender = "me", text = "Want to grab coffee later?", time = "Mon"},
|
||||
{sender = "them", text = "Sure! What time?", time = "Mon"},
|
||||
{sender = "me", text = "How about 4pm at the usual place?", time = "Mon"},
|
||||
{sender = "them", text = "See you at the coffee shop!", time = "Mon"},
|
||||
}
|
||||
},
|
||||
{
|
||||
id = "6",
|
||||
name = "Frank Miller",
|
||||
phone = "+1 555-0601",
|
||||
last_message = "Great game last night!",
|
||||
time = "Sun",
|
||||
unread = 0,
|
||||
messages = {
|
||||
{sender = "them", text = "Did you watch the game?", time = "Sun"},
|
||||
{sender = "me", text = "Yes! It was amazing!", time = "Sun"},
|
||||
{sender = "them", text = "Great game last night!", time = "Sun"},
|
||||
}
|
||||
},
|
||||
{
|
||||
id = "7",
|
||||
name = "Grace Lee",
|
||||
phone = "+1 555-0701",
|
||||
last_message = "Happy birthday! :)",
|
||||
time = "Sat",
|
||||
unread = 0,
|
||||
messages = {
|
||||
{sender = "them", text = "Happy birthday! :)", time = "Sat"},
|
||||
{sender = "me", text = "Thank you so much! :)", time = "Sat"},
|
||||
}
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
-- Initialize messages app
|
||||
function initMessages(doc)
|
||||
print("[Messages] Initializing...")
|
||||
messages_doc = doc
|
||||
initConversationsData()
|
||||
renderConversations()
|
||||
end
|
||||
|
||||
-- Render conversation list
|
||||
function renderConversations()
|
||||
if not messages_doc then return end
|
||||
|
||||
local container = messages_doc:GetElementById("conversations-list")
|
||||
if not container then return end
|
||||
|
||||
local html = ""
|
||||
for _, conv in ipairs(conversations) do
|
||||
local color = getAvatarColor(conv.name)
|
||||
local initial = conv.name:sub(1, 1):upper()
|
||||
|
||||
local unread_badge = ""
|
||||
if conv.unread > 0 then
|
||||
unread_badge = [[<div class="conversation-unread">]] .. conv.unread .. [[</div>]]
|
||||
end
|
||||
|
||||
html = html .. [[
|
||||
<div class="conversation-item" onclick="openConversation(']] .. conv.id .. [[')">
|
||||
<div class="conversation-avatar" style="background-color: ]] .. color .. [[;">]] .. initial .. [[</div>
|
||||
<div class="conversation-content">
|
||||
<div class="conversation-header">
|
||||
<span class="conversation-name">]] .. conv.name .. [[</span>
|
||||
<span class="conversation-time">]] .. conv.time .. [[</span>
|
||||
</div>
|
||||
<div class="conversation-preview">]] .. conv.last_message .. [[</div>
|
||||
</div>
|
||||
]] .. unread_badge .. [[
|
||||
</div>
|
||||
]]
|
||||
end
|
||||
|
||||
container.inner_rml = html
|
||||
end
|
||||
|
||||
-- Open a conversation
|
||||
function openConversation(conv_id)
|
||||
print("[Messages] Opening conversation: " .. conv_id)
|
||||
|
||||
-- Find conversation
|
||||
for _, conv in ipairs(conversations) do
|
||||
if conv.id == conv_id then
|
||||
current_conversation = conv
|
||||
current_messages = conv.messages
|
||||
conv.unread = 0 -- Mark as read
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if current_conversation then
|
||||
-- Store for chat screen
|
||||
if mosis and mosis.state then
|
||||
mosis.state.set("current_chat", {
|
||||
id = current_conversation.id,
|
||||
name = current_conversation.name,
|
||||
phone = current_conversation.phone
|
||||
})
|
||||
end
|
||||
|
||||
-- Navigate to chat
|
||||
if navigateTo then
|
||||
navigateTo("chat")
|
||||
else
|
||||
-- Inline chat view
|
||||
showChatInline()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Show chat inline
|
||||
function showChatInline()
|
||||
if not messages_doc then return end
|
||||
|
||||
local list = messages_doc:GetElementById("conversations-container")
|
||||
local chat = messages_doc:GetElementById("chat-container")
|
||||
|
||||
if list and chat then
|
||||
list.style.display = "none"
|
||||
chat.style.display = "flex"
|
||||
renderChat()
|
||||
end
|
||||
end
|
||||
|
||||
-- Hide chat and return to list
|
||||
function hideChat()
|
||||
if not messages_doc then return end
|
||||
|
||||
local list = messages_doc:GetElementById("conversations-container")
|
||||
local chat = messages_doc:GetElementById("chat-container")
|
||||
|
||||
if list and chat then
|
||||
chat.style.display = "none"
|
||||
list.style.display = "flex"
|
||||
renderConversations() -- Refresh to update unread counts
|
||||
end
|
||||
|
||||
current_conversation = nil
|
||||
end
|
||||
|
||||
-- Render chat messages
|
||||
function renderChat()
|
||||
if not messages_doc or not current_conversation then return end
|
||||
|
||||
-- Update header
|
||||
local name_el = messages_doc:GetElementById("chat-name")
|
||||
local avatar_el = messages_doc:GetElementById("chat-avatar")
|
||||
|
||||
if name_el then
|
||||
name_el.inner_rml = current_conversation.name
|
||||
end
|
||||
|
||||
if avatar_el then
|
||||
local color = getAvatarColor(current_conversation.name)
|
||||
local initial = current_conversation.name:sub(1, 1):upper()
|
||||
avatar_el.style["background-color"] = color
|
||||
avatar_el.inner_rml = initial
|
||||
end
|
||||
|
||||
-- Render messages
|
||||
local container = messages_doc:GetElementById("chat-messages")
|
||||
if not container then return end
|
||||
|
||||
local html = ""
|
||||
for _, msg in ipairs(current_messages) do
|
||||
local class = msg.sender == "me" and "message-sent" or "message-received"
|
||||
html = html .. [[
|
||||
<div class="message-bubble ]] .. class .. [[">]] .. msg.text .. [[</div>
|
||||
]]
|
||||
end
|
||||
|
||||
container.inner_rml = html
|
||||
|
||||
-- Scroll to bottom
|
||||
-- Note: RmlUi may need specific handling for scroll
|
||||
end
|
||||
|
||||
-- Send a message
|
||||
function sendMessage()
|
||||
if not messages_doc or not current_conversation then return end
|
||||
|
||||
local input = messages_doc:GetElementById("message-input")
|
||||
if not input then return end
|
||||
|
||||
local text = input.value or ""
|
||||
if text == "" then return end
|
||||
|
||||
print("[Messages] Sending: " .. text)
|
||||
|
||||
-- Add message to current conversation
|
||||
table.insert(current_messages, {
|
||||
sender = "me",
|
||||
text = text,
|
||||
time = "Just now"
|
||||
})
|
||||
|
||||
-- Update conversation preview
|
||||
current_conversation.last_message = text
|
||||
current_conversation.time = "Just now"
|
||||
|
||||
-- Clear input
|
||||
input.value = ""
|
||||
|
||||
-- Re-render chat
|
||||
renderChat()
|
||||
|
||||
-- Simulate reply after delay
|
||||
if setTimeout then
|
||||
setTimeout(function()
|
||||
simulateReply()
|
||||
end, 2000 + math.random(1000, 3000))
|
||||
end
|
||||
end
|
||||
|
||||
-- Simulate a reply
|
||||
function simulateReply()
|
||||
if not current_conversation then return end
|
||||
|
||||
local replies = {
|
||||
"That's great!",
|
||||
"I see",
|
||||
"Sounds good!",
|
||||
"Let me think about it",
|
||||
"Sure thing!",
|
||||
"OK!",
|
||||
"Thanks!",
|
||||
"Got it",
|
||||
"Nice!",
|
||||
"Interesting..."
|
||||
}
|
||||
|
||||
local reply = replies[math.random(#replies)]
|
||||
|
||||
table.insert(current_messages, {
|
||||
sender = "them",
|
||||
text = reply,
|
||||
time = "Just now"
|
||||
})
|
||||
|
||||
current_conversation.last_message = reply
|
||||
current_conversation.time = "Just now"
|
||||
|
||||
renderChat()
|
||||
end
|
||||
|
||||
-- Handle input keypress (for Enter to send)
|
||||
function onMessageKeypress(event)
|
||||
if event.key == "Return" or event.key == "Enter" then
|
||||
sendMessage()
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Start new conversation
|
||||
function newConversation()
|
||||
print("[Messages] New conversation")
|
||||
if showToast then
|
||||
showToast("New message")
|
||||
end
|
||||
end
|
||||
|
||||
-- Search conversations
|
||||
function searchConversations(query)
|
||||
print("[Messages] Searching: " .. query)
|
||||
-- TODO: Implement search filtering
|
||||
end
|
||||
|
||||
-- Delete conversation
|
||||
function deleteConversation(conv_id)
|
||||
print("[Messages] Deleting conversation: " .. conv_id)
|
||||
|
||||
for i, conv in ipairs(conversations) do
|
||||
if conv.id == conv_id then
|
||||
table.remove(conversations, i)
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
renderConversations()
|
||||
|
||||
if showToast then
|
||||
showToast("Conversation deleted")
|
||||
end
|
||||
end
|
||||
|
||||
-- Call contact from chat
|
||||
function callFromChat()
|
||||
if not current_conversation then return end
|
||||
|
||||
print("[Messages] Calling from chat: " .. current_conversation.name)
|
||||
|
||||
if mosis and mosis.state then
|
||||
mosis.state.set("current_call", {
|
||||
number = current_conversation.phone,
|
||||
name = current_conversation.name
|
||||
})
|
||||
end
|
||||
|
||||
if navigateTo then
|
||||
navigateTo("calling")
|
||||
else
|
||||
if showToast then
|
||||
showToast("Calling " .. current_conversation.name)
|
||||
end
|
||||
end
|
||||
end
|
||||
299
base-apps/com.mosis.messages/messages.rml
Normal file
299
base-apps/com.mosis.messages/messages.rml
Normal file
@@ -0,0 +1,299 @@
|
||||
<rml>
|
||||
<head>
|
||||
<link type="text/rcss" href="../../ui/html.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/theme.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/components.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/layout.rcss"/>
|
||||
<script src="../../scripts/navigation.lua"></script>
|
||||
<script src="../../scripts/layout.lua"></script>
|
||||
<script src="messages.lua"></script>
|
||||
<title>Messages</title>
|
||||
<style>
|
||||
.conversations-list {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.conversation-item:active {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.conversation-avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 28px;
|
||||
margin-right: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.conversation-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conversation-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.conversation-name {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.conversation-time {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.conversation-preview {
|
||||
font-size: 16px;
|
||||
color: #B3B3B3;
|
||||
margin-top: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.conversation-unread {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
background-color: #BB86FC;
|
||||
color: #000000;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Chat View */
|
||||
#chat-container {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background-color: #1E1E1E;
|
||||
}
|
||||
|
||||
.chat-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 24px;
|
||||
margin-right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.chat-header-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-header-name {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.chat-header-status {
|
||||
font-size: 14px;
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
max-width: 75%;
|
||||
padding: 12px 16px;
|
||||
border-radius: 18px;
|
||||
font-size: 18px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.message-sent {
|
||||
align-self: flex-end;
|
||||
background-color: #BB86FC;
|
||||
color: #000000;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.message-received {
|
||||
align-self: flex-start;
|
||||
background-color: #2D2D2D;
|
||||
color: #FFFFFF;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-input-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background-color: #1E1E1E;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
padding: 12px 18px;
|
||||
background-color: #2D2D2D;
|
||||
border-radius: 24px;
|
||||
color: #FFFFFF;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.chat-input:hover {
|
||||
background-color: #3D3D3D;
|
||||
}
|
||||
|
||||
.chat-input:focus {
|
||||
background-color: #353535;
|
||||
}
|
||||
|
||||
.chat-send-btn {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 28px;
|
||||
background-color: #BB86FC;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-send-btn:hover {
|
||||
background-color: #9C64FC;
|
||||
}
|
||||
|
||||
.chat-send-btn:active {
|
||||
background-color: #7C44DC;
|
||||
}
|
||||
|
||||
.chat-send-btn img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="app-screen" onload="initLayout(document); initMessages(document)" data-model="messages">
|
||||
<!-- System Status Bar -->
|
||||
<div class="system-status-bar">
|
||||
<span id="status-time" class="system-status-time">12:30</span>
|
||||
<div class="system-status-icons">
|
||||
<img src="../../icons/wifi.tga"/>
|
||||
<img src="../../icons/signal.tga"/>
|
||||
<img src="../../icons/battery.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conversations List Container -->
|
||||
<div id="conversations-container" style="display: flex; flex-direction: column; flex: 1;">
|
||||
<!-- App Bar -->
|
||||
<div class="app-bar">
|
||||
<div class="app-bar-back" onclick="goBack()">
|
||||
<img src="../../icons/back.tga"/>
|
||||
</div>
|
||||
<span class="app-bar-title">Messages</span>
|
||||
<div class="app-bar-actions">
|
||||
<div class="app-bar-action">
|
||||
<img src="../../icons/search.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conversations List -->
|
||||
<div class="app-content with-nav">
|
||||
<div class="conversations-list" id="conversations-list">
|
||||
<!-- Populated by Lua -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FAB -->
|
||||
<div class="btn-fab" onclick="newConversation()">
|
||||
<img src="../../icons/add.tga" style="width: 32px; height: 32px;"/>
|
||||
</div>
|
||||
|
||||
<!-- System Navigation Bar -->
|
||||
<div class="system-nav-bar">
|
||||
<div class="system-nav-btn" onclick="onBackPressed()">
|
||||
<img src="../../icons/back.tga"/>
|
||||
</div>
|
||||
<div class="system-nav-home" onclick="onHomePressed()"></div>
|
||||
<div class="system-nav-btn" onclick="onRecentPressed()">
|
||||
<img src="../../icons/menu.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Container -->
|
||||
<div id="chat-container">
|
||||
<!-- Chat Header -->
|
||||
<div class="app-bar">
|
||||
<div class="app-bar-back" onclick="hideChat()">
|
||||
<img src="../../icons/back.tga"/>
|
||||
</div>
|
||||
<div class="chat-avatar" id="chat-avatar" style="background-color: #4CAF50;">J</div>
|
||||
<div class="chat-header-info">
|
||||
<div class="chat-header-name" id="chat-name">Contact</div>
|
||||
<div class="chat-header-status">Online</div>
|
||||
</div>
|
||||
<div class="btn-icon" onclick="callFromChat()">
|
||||
<img src="../../icons/phone.tga" style="width: 32px; height: 32px;"/>
|
||||
</div>
|
||||
<div class="btn-icon">
|
||||
<img src="../../icons/more.tga" style="width: 32px; height: 32px;"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="chat-messages" id="chat-messages">
|
||||
<!-- Populated by Lua -->
|
||||
</div>
|
||||
|
||||
<!-- Input Bar -->
|
||||
<div class="chat-input-bar">
|
||||
<div class="btn-icon" style="width: 48px; height: 48px;">
|
||||
<img src="../../icons/add.tga" style="width: 28px; height: 28px;"/>
|
||||
</div>
|
||||
<input class="chat-input" type="text" placeholder="Type a message..." id="message-input"/>
|
||||
<div class="chat-send-btn" onclick="sendMessage()">
|
||||
<img src="../../icons/send.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</rml>
|
||||
18
base-apps/com.mosis.music/manifest.json
Normal file
18
base-apps/com.mosis.music/manifest.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "com.mosis.music",
|
||||
"name": "Music",
|
||||
"version": "1.0.0",
|
||||
"version_code": 1,
|
||||
"entry": "music.rml",
|
||||
"icon": "../../icons/music.tga",
|
||||
"description": "Music player application",
|
||||
"developer": {
|
||||
"name": "Mosis Team",
|
||||
"email": "dev@mosis.dev"
|
||||
},
|
||||
"is_system_app": true,
|
||||
"permissions": [
|
||||
"storage"
|
||||
],
|
||||
"min_api_version": 1
|
||||
}
|
||||
388
base-apps/com.mosis.music/music.lua
Normal file
388
base-apps/com.mosis.music/music.lua
Normal file
@@ -0,0 +1,388 @@
|
||||
-- music.lua - Music player functionality
|
||||
-- Handles playback, playlists, library, and now playing
|
||||
|
||||
local music_doc = nil
|
||||
|
||||
-- Player state
|
||||
local player_state = {
|
||||
is_playing = false,
|
||||
is_shuffled = false,
|
||||
repeat_mode = "off", -- off, all, one
|
||||
current_time = 0,
|
||||
duration = 234, -- 3:54
|
||||
volume = 80
|
||||
}
|
||||
|
||||
-- Current track
|
||||
local current_track = {
|
||||
id = "1",
|
||||
title = "Midnight City",
|
||||
artist = "M83",
|
||||
album = "Hurry Up, We're Dreaming",
|
||||
duration = 234,
|
||||
art_color = "#667eea"
|
||||
}
|
||||
|
||||
-- Playlists
|
||||
local playlists = {
|
||||
{id = "liked", name = "Liked Songs", count = 127, color = "#dc2626"},
|
||||
{id = "daily1", name = "Daily Mix 1", count = 50, color = "#667eea"},
|
||||
{id = "release", name = "Release Radar", count = 30, color = "#16a34a"},
|
||||
{id = "chill", name = "Chill Vibes", count = 45, color = "#f093fb"},
|
||||
{id = "workout", name = "Workout Mix", count = 35, color = "#2563eb"},
|
||||
{id = "focus", name = "Focus Flow", count = 40, color = "#4facfe"}
|
||||
}
|
||||
|
||||
-- Recently played
|
||||
local recently_played = {
|
||||
{id = "pop", name = "Pop Hits", type = "Playlist", color = "#43e97b"},
|
||||
{id = "electronic", name = "Electronic", type = "Playlist", color = "#fa709a"},
|
||||
{id = "jazz", name = "Jazz Classics", type = "Playlist", color = "#667eea"},
|
||||
{id = "rock", name = "Rock Legends", type = "Playlist", color = "#f093fb"}
|
||||
}
|
||||
|
||||
-- Song queue
|
||||
local queue = {
|
||||
{id = "1", title = "Midnight City", artist = "M83", duration = 234},
|
||||
{id = "2", title = "Intro", artist = "The xx", duration = 128},
|
||||
{id = "3", title = "Retrograde", artist = "James Blake", duration = 233},
|
||||
{id = "4", title = "Tame Impala", artist = "The Less I Know The Better", duration = 218},
|
||||
{id = "5", title = "Redbone", artist = "Childish Gambino", duration = 327}
|
||||
}
|
||||
|
||||
local current_queue_index = 1
|
||||
local timer_id = nil
|
||||
|
||||
-- Initialize music app
|
||||
function initMusic(doc)
|
||||
print("[Music] Initializing...")
|
||||
music_doc = doc
|
||||
updateNowPlaying()
|
||||
updateMiniPlayer()
|
||||
renderPlaylists()
|
||||
renderRecentlyPlayed()
|
||||
end
|
||||
|
||||
-- Format time (seconds to mm:ss)
|
||||
local function formatTime(seconds)
|
||||
local mins = math.floor(seconds / 60)
|
||||
local secs = seconds % 60
|
||||
return string.format("%d:%02d", mins, secs)
|
||||
end
|
||||
|
||||
-- Update now playing display
|
||||
function updateNowPlaying()
|
||||
if not music_doc then return end
|
||||
|
||||
local title = music_doc:GetElementById("now-playing-title")
|
||||
local artist = music_doc:GetElementById("now-playing-artist")
|
||||
|
||||
if title then
|
||||
title.inner_rml = current_track.title
|
||||
end
|
||||
if artist then
|
||||
artist.inner_rml = current_track.artist
|
||||
end
|
||||
end
|
||||
|
||||
-- Update mini player
|
||||
function updateMiniPlayer()
|
||||
if not music_doc then return end
|
||||
|
||||
local title = music_doc:GetElementById("mini-player-title")
|
||||
local artist = music_doc:GetElementById("mini-player-artist")
|
||||
local art = music_doc:GetElementById("mini-player-art")
|
||||
local play_btn = music_doc:GetElementById("mini-play-btn")
|
||||
|
||||
if title then
|
||||
title.inner_rml = current_track.title
|
||||
end
|
||||
if artist then
|
||||
artist.inner_rml = current_track.artist
|
||||
end
|
||||
if art then
|
||||
art.style["background-color"] = current_track.art_color
|
||||
art.inner_rml = current_track.title:sub(1,1):upper()
|
||||
end
|
||||
if play_btn then
|
||||
local icon = player_state.is_playing and "pause.tga" or "play.tga"
|
||||
play_btn.inner_rml = [[<img src="../../icons/]] .. icon .. [[" style="width: 28px; height: 28px;"/>]]
|
||||
end
|
||||
end
|
||||
|
||||
-- Toggle play/pause
|
||||
function togglePlay()
|
||||
player_state.is_playing = not player_state.is_playing
|
||||
print("[Music] " .. (player_state.is_playing and "Playing" or "Paused"))
|
||||
|
||||
if player_state.is_playing then
|
||||
startPlaybackTimer()
|
||||
else
|
||||
stopPlaybackTimer()
|
||||
end
|
||||
|
||||
updateMiniPlayer()
|
||||
updatePlayButton()
|
||||
end
|
||||
|
||||
-- Start playback timer
|
||||
function startPlaybackTimer()
|
||||
if setInterval then
|
||||
timer_id = setInterval(function()
|
||||
if player_state.is_playing then
|
||||
player_state.current_time = player_state.current_time + 1
|
||||
if player_state.current_time >= current_track.duration then
|
||||
nextTrack()
|
||||
end
|
||||
updateProgress()
|
||||
end
|
||||
end, 1000)
|
||||
end
|
||||
end
|
||||
|
||||
-- Stop playback timer
|
||||
function stopPlaybackTimer()
|
||||
if timer_id and clearInterval then
|
||||
clearInterval(timer_id)
|
||||
timer_id = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- Update play button
|
||||
function updatePlayButton()
|
||||
if not music_doc then return end
|
||||
|
||||
local btn = music_doc:GetElementById("play-btn")
|
||||
if btn then
|
||||
local icon = player_state.is_playing and "pause.tga" or "play.tga"
|
||||
btn.inner_rml = [[<img src="../../icons/]] .. icon .. [[" style="width: 48px; height: 48px;"/>]]
|
||||
end
|
||||
end
|
||||
|
||||
-- Update progress display
|
||||
function updateProgress()
|
||||
if not music_doc then return end
|
||||
|
||||
local current = music_doc:GetElementById("current-time")
|
||||
local total = music_doc:GetElementById("total-time")
|
||||
local progress = music_doc:GetElementById("progress-bar")
|
||||
|
||||
if current then
|
||||
current.inner_rml = formatTime(player_state.current_time)
|
||||
end
|
||||
if total then
|
||||
total.inner_rml = formatTime(current_track.duration)
|
||||
end
|
||||
if progress then
|
||||
local percent = (player_state.current_time / current_track.duration) * 100
|
||||
progress.style.width = percent .. "%"
|
||||
end
|
||||
end
|
||||
|
||||
-- Next track
|
||||
function nextTrack()
|
||||
print("[Music] Next track")
|
||||
|
||||
if player_state.is_shuffled then
|
||||
current_queue_index = math.random(1, #queue)
|
||||
else
|
||||
current_queue_index = current_queue_index + 1
|
||||
if current_queue_index > #queue then
|
||||
if player_state.repeat_mode == "all" then
|
||||
current_queue_index = 1
|
||||
else
|
||||
current_queue_index = #queue
|
||||
player_state.is_playing = false
|
||||
stopPlaybackTimer()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
loadTrack(queue[current_queue_index])
|
||||
end
|
||||
|
||||
-- Previous track
|
||||
function previousTrack()
|
||||
print("[Music] Previous track")
|
||||
|
||||
if player_state.current_time > 3 then
|
||||
-- Restart current track
|
||||
player_state.current_time = 0
|
||||
else
|
||||
current_queue_index = current_queue_index - 1
|
||||
if current_queue_index < 1 then
|
||||
current_queue_index = 1
|
||||
end
|
||||
loadTrack(queue[current_queue_index])
|
||||
end
|
||||
|
||||
updateProgress()
|
||||
end
|
||||
|
||||
-- Load a track
|
||||
function loadTrack(track)
|
||||
current_track.id = track.id
|
||||
current_track.title = track.title
|
||||
current_track.artist = track.artist
|
||||
current_track.duration = track.duration
|
||||
player_state.current_time = 0
|
||||
|
||||
updateNowPlaying()
|
||||
updateMiniPlayer()
|
||||
updateProgress()
|
||||
|
||||
print("[Music] Now playing: " .. track.title .. " - " .. track.artist)
|
||||
|
||||
if showToast then
|
||||
showToast("Now playing: " .. track.title)
|
||||
end
|
||||
end
|
||||
|
||||
-- Toggle shuffle
|
||||
function toggleShuffle()
|
||||
player_state.is_shuffled = not player_state.is_shuffled
|
||||
print("[Music] Shuffle: " .. tostring(player_state.is_shuffled))
|
||||
|
||||
local btn = music_doc:GetElementById("shuffle-btn")
|
||||
if btn then
|
||||
if player_state.is_shuffled then
|
||||
btn:SetClass("active", true)
|
||||
else
|
||||
btn:SetClass("active", false)
|
||||
end
|
||||
end
|
||||
|
||||
if showToast then
|
||||
showToast(player_state.is_shuffled and "Shuffle on" or "Shuffle off")
|
||||
end
|
||||
end
|
||||
|
||||
-- Toggle repeat
|
||||
function toggleRepeat()
|
||||
if player_state.repeat_mode == "off" then
|
||||
player_state.repeat_mode = "all"
|
||||
elseif player_state.repeat_mode == "all" then
|
||||
player_state.repeat_mode = "one"
|
||||
else
|
||||
player_state.repeat_mode = "off"
|
||||
end
|
||||
|
||||
print("[Music] Repeat: " .. player_state.repeat_mode)
|
||||
|
||||
local btn = music_doc:GetElementById("repeat-btn")
|
||||
if btn then
|
||||
if player_state.repeat_mode ~= "off" then
|
||||
btn:SetClass("active", true)
|
||||
else
|
||||
btn:SetClass("active", false)
|
||||
end
|
||||
end
|
||||
|
||||
if showToast then
|
||||
local msg = "Repeat: " .. player_state.repeat_mode
|
||||
showToast(msg)
|
||||
end
|
||||
end
|
||||
|
||||
-- Toggle like
|
||||
function toggleLike()
|
||||
print("[Music] Toggle like")
|
||||
if showToast then
|
||||
showToast("Added to Liked Songs")
|
||||
end
|
||||
end
|
||||
|
||||
-- Render playlists
|
||||
function renderPlaylists()
|
||||
if not music_doc then return end
|
||||
|
||||
local container = music_doc:GetElementById("quick-access")
|
||||
if not container then return end
|
||||
|
||||
local html = ""
|
||||
for i, pl in ipairs(playlists) do
|
||||
if i <= 6 then
|
||||
local initial = pl.name:sub(1,1):upper()
|
||||
html = html .. [[
|
||||
<div class="quick-card" onclick="openPlaylist(']] .. pl.id .. [[')">
|
||||
<div class="quick-card-art" style="background-color: ]] .. pl.color .. [[;">]] .. initial .. [[</div>
|
||||
<span class="quick-card-title">]] .. pl.name .. [[</span>
|
||||
</div>
|
||||
]]
|
||||
end
|
||||
end
|
||||
|
||||
container.inner_rml = html
|
||||
end
|
||||
|
||||
-- Render recently played
|
||||
function renderRecentlyPlayed()
|
||||
if not music_doc then return end
|
||||
|
||||
local container = music_doc:GetElementById("recent-row")
|
||||
if not container then return end
|
||||
|
||||
local html = ""
|
||||
for _, item in ipairs(recently_played) do
|
||||
local initial = item.name:sub(1,1):upper()
|
||||
html = html .. [[
|
||||
<div class="recent-item" onclick="openPlaylist(']] .. item.id .. [[')">
|
||||
<div class="recent-art" style="background-color: ]] .. item.color .. [[;">]] .. initial .. [[</div>
|
||||
<div class="recent-title">]] .. item.name .. [[</div>
|
||||
<div class="recent-subtitle">]] .. item.type .. [[</div>
|
||||
</div>
|
||||
]]
|
||||
end
|
||||
|
||||
container.inner_rml = html
|
||||
end
|
||||
|
||||
-- Open playlist
|
||||
function openPlaylist(playlist_id)
|
||||
print("[Music] Opening playlist: " .. playlist_id)
|
||||
if navigateTo then
|
||||
navigateTo("playlist_" .. playlist_id)
|
||||
else
|
||||
if showToast then
|
||||
showToast("Playlist: " .. playlist_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Open now playing
|
||||
function openNowPlaying()
|
||||
print("[Music] Opening now playing...")
|
||||
if navigateTo then
|
||||
navigateTo("now_playing")
|
||||
end
|
||||
end
|
||||
|
||||
-- Open search
|
||||
function openSearch()
|
||||
print("[Music] Opening search...")
|
||||
if navigateTo then
|
||||
navigateTo("music_search")
|
||||
else
|
||||
if showToast then
|
||||
showToast("Search music")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Open library
|
||||
function openLibrary()
|
||||
print("[Music] Opening library...")
|
||||
if navigateTo then
|
||||
navigateTo("music_library")
|
||||
else
|
||||
if showToast then
|
||||
showToast("Your library")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Seek to position (0-1)
|
||||
function seekTo(position)
|
||||
player_state.current_time = math.floor(position * current_track.duration)
|
||||
updateProgress()
|
||||
end
|
||||
@@ -3,36 +3,40 @@
|
||||
<link type="text/rcss" href="../../ui/html.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/theme.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/components.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/layout.rcss"/>
|
||||
<script src="../../scripts/navigation.lua"></script>
|
||||
<script src="../../scripts/layout.lua"></script>
|
||||
<script src="music.lua"></script>
|
||||
<title>Music</title>
|
||||
<style>
|
||||
.music-screen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #121212;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.music-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding-bottom: 140px;
|
||||
}
|
||||
|
||||
/* Now Playing Mini Bar */
|
||||
.mini-player {
|
||||
position: absolute;
|
||||
bottom: 56px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background-color: #282828;
|
||||
border-top: 1px solid #333333;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mini-player:hover {
|
||||
background-color: #333333;
|
||||
}
|
||||
|
||||
.mini-player-art {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
background-color: #667eea;
|
||||
margin-right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -46,30 +50,25 @@
|
||||
}
|
||||
|
||||
.mini-player-title {
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
color: #FFFFFF;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mini-player-artist {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
color: #B3B3B3;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.mini-player-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mini-control-btn {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 28px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.mini-control-btn:hover {
|
||||
@@ -77,26 +76,26 @@
|
||||
}
|
||||
|
||||
.mini-control-btn img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Section Headers */
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24px 16px 12px 16px;
|
||||
padding: 20px 16px 12px 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 22px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.section-action {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #B3B3B3;
|
||||
cursor: pointer;
|
||||
@@ -106,7 +105,6 @@
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Recently Played Row */
|
||||
.recent-row {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
@@ -115,43 +113,49 @@
|
||||
}
|
||||
|
||||
.recent-item {
|
||||
min-width: 130px;
|
||||
min-width: 120px;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.recent-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.recent-art {
|
||||
width: 130px;
|
||||
height: 130px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 40px;
|
||||
font-size: 36px;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.recent-title {
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
color: #FFFFFF;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.recent-subtitle {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
color: #B3B3B3;
|
||||
}
|
||||
|
||||
/* Quick Access Cards */
|
||||
.quick-access {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.quick-card {
|
||||
width: 48%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #282828;
|
||||
@@ -172,18 +176,17 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
font-size: 18px;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.quick-card-title {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* Playlist Row */
|
||||
.playlist-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -218,13 +221,16 @@
|
||||
}
|
||||
|
||||
.playlist-meta {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
color: #B3B3B3;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Bottom Navigation */
|
||||
.music-bottom-nav {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
height: 56px;
|
||||
background-color: #1E1E1E;
|
||||
@@ -241,75 +247,106 @@
|
||||
color: #B3B3B3;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.nav-item img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-bottom: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nav-item span {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Color palette for album arts */
|
||||
.bg-gradient-1 { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
|
||||
.bg-gradient-2 { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); }
|
||||
.bg-gradient-3 { background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); }
|
||||
.bg-gradient-4 { background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%); }
|
||||
.bg-gradient-5 { background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); }
|
||||
.bg-gradient-6 { background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%); }
|
||||
/* Progress bar for mini player */
|
||||
.progress-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background-color: #404040;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
background-color: #1DB954;
|
||||
}
|
||||
|
||||
.bg-gradient-1 { background-color: #667eea; }
|
||||
.bg-gradient-2 { background-color: #f093fb; }
|
||||
.bg-gradient-3 { background-color: #4facfe; }
|
||||
.bg-gradient-4 { background-color: #43e97b; }
|
||||
.bg-gradient-5 { background-color: #fa709a; }
|
||||
.bg-solid-purple { background-color: #7c3aed; }
|
||||
.bg-solid-red { background-color: #dc2626; }
|
||||
.bg-solid-green { background-color: #16a34a; }
|
||||
.bg-solid-blue { background-color: #2563eb; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="music-screen">
|
||||
<body class="app-screen" onload="initLayout(document); initMusic(document)">
|
||||
<!-- System Status Bar -->
|
||||
<div class="system-status-bar">
|
||||
<span id="status-time" class="system-status-time">12:30</span>
|
||||
<div class="system-status-icons">
|
||||
<img src="../../icons/wifi.tga"/>
|
||||
<img src="../../icons/signal.tga"/>
|
||||
<img src="../../icons/battery.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App Bar -->
|
||||
<div class="app-bar">
|
||||
<div class="app-bar-nav btn-icon" onclick="goBack()">
|
||||
<img src="../../icons/back.tga" style="width: 32px; height: 32px;"/>
|
||||
<div class="app-bar-back" onclick="goBack()">
|
||||
<img src="../../icons/back.tga"/>
|
||||
</div>
|
||||
<span class="app-bar-title">Music</span>
|
||||
<div class="btn-icon">
|
||||
<img src="../../icons/search.tga" style="width: 32px; height: 32px;"/>
|
||||
<div class="app-bar-actions">
|
||||
<div class="app-bar-action" onclick="openSearch()">
|
||||
<img src="../../icons/search.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="music-content">
|
||||
<!-- Good Morning Section -->
|
||||
<!-- Good Afternoon Section -->
|
||||
<div class="section-header">
|
||||
<span class="section-title">Good afternoon</span>
|
||||
</div>
|
||||
|
||||
<!-- Quick Access Grid -->
|
||||
<div class="quick-access">
|
||||
<div class="quick-card">
|
||||
<div class="quick-access" id="quick-access">
|
||||
<div class="quick-card" onclick="openPlaylist('liked')">
|
||||
<div class="quick-card-art bg-solid-red">L</div>
|
||||
<span class="quick-card-title">Liked Songs</span>
|
||||
</div>
|
||||
<div class="quick-card">
|
||||
<div class="quick-card" onclick="openPlaylist('daily1')">
|
||||
<div class="quick-card-art bg-gradient-1">D</div>
|
||||
<span class="quick-card-title">Daily Mix 1</span>
|
||||
</div>
|
||||
<div class="quick-card">
|
||||
<div class="quick-card" onclick="openPlaylist('release')">
|
||||
<div class="quick-card-art bg-solid-green">R</div>
|
||||
<span class="quick-card-title">Release Radar</span>
|
||||
</div>
|
||||
<div class="quick-card">
|
||||
<div class="quick-card" onclick="openPlaylist('chill')">
|
||||
<div class="quick-card-art bg-gradient-2">C</div>
|
||||
<span class="quick-card-title">Chill Vibes</span>
|
||||
</div>
|
||||
<div class="quick-card">
|
||||
<div class="quick-card" onclick="openPlaylist('workout')">
|
||||
<div class="quick-card-art bg-solid-blue">W</div>
|
||||
<span class="quick-card-title">Workout Mix</span>
|
||||
</div>
|
||||
<div class="quick-card">
|
||||
<div class="quick-card" onclick="openPlaylist('focus')">
|
||||
<div class="quick-card-art bg-gradient-3">F</div>
|
||||
<span class="quick-card-title">Focus Flow</span>
|
||||
</div>
|
||||
@@ -321,23 +358,23 @@
|
||||
<span class="section-action">SEE ALL</span>
|
||||
</div>
|
||||
|
||||
<div class="recent-row">
|
||||
<div class="recent-item">
|
||||
<div class="recent-row" id="recent-row">
|
||||
<div class="recent-item" onclick="openPlaylist('pop')">
|
||||
<div class="recent-art bg-gradient-4">P</div>
|
||||
<div class="recent-title">Pop Hits</div>
|
||||
<div class="recent-subtitle">Playlist</div>
|
||||
</div>
|
||||
<div class="recent-item">
|
||||
<div class="recent-item" onclick="openPlaylist('electronic')">
|
||||
<div class="recent-art bg-gradient-5">E</div>
|
||||
<div class="recent-title">Electronic</div>
|
||||
<div class="recent-subtitle">Playlist</div>
|
||||
</div>
|
||||
<div class="recent-item">
|
||||
<div class="recent-item" onclick="openPlaylist('jazz')">
|
||||
<div class="recent-art bg-gradient-1">J</div>
|
||||
<div class="recent-title">Jazz Classics</div>
|
||||
<div class="recent-subtitle">Playlist</div>
|
||||
</div>
|
||||
<div class="recent-item">
|
||||
<div class="recent-item" onclick="openPlaylist('rock')">
|
||||
<div class="recent-art bg-gradient-2">R</div>
|
||||
<div class="recent-title">Rock Legends</div>
|
||||
<div class="recent-subtitle">Playlist</div>
|
||||
@@ -350,7 +387,7 @@
|
||||
<span class="section-action">SEE ALL</span>
|
||||
</div>
|
||||
|
||||
<div class="playlist-item">
|
||||
<div class="playlist-item" onclick="openPlaylist('daily1')">
|
||||
<div class="playlist-art bg-gradient-3">1</div>
|
||||
<div class="playlist-info">
|
||||
<div class="playlist-title">Daily Mix 1</div>
|
||||
@@ -358,7 +395,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="playlist-item">
|
||||
<div class="playlist-item" onclick="openPlaylist('daily2')">
|
||||
<div class="playlist-art bg-gradient-4">2</div>
|
||||
<div class="playlist-info">
|
||||
<div class="playlist-title">Daily Mix 2</div>
|
||||
@@ -366,35 +403,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="playlist-item">
|
||||
<div class="playlist-item" onclick="openPlaylist('discover')">
|
||||
<div class="playlist-art bg-gradient-5">D</div>
|
||||
<div class="playlist-info">
|
||||
<div class="playlist-title">Discover Weekly</div>
|
||||
<div class="playlist-meta">Your weekly mixtape</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="playlist-item">
|
||||
<div class="playlist-art bg-solid-green">R</div>
|
||||
<div class="playlist-info">
|
||||
<div class="playlist-title">Release Radar</div>
|
||||
<div class="playlist-meta">New music from artists you follow</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mini Player -->
|
||||
<div class="mini-player">
|
||||
<div class="mini-player-art">M</div>
|
||||
<div class="mini-player-info">
|
||||
<div class="mini-player-title">Midnight City</div>
|
||||
<div class="mini-player-artist">M83</div>
|
||||
<div class="mini-player" onclick="openNowPlaying()">
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar" id="progress-bar"></div>
|
||||
</div>
|
||||
<div class="mini-player-controls">
|
||||
<div class="mini-control-btn">
|
||||
<div class="mini-player-art" id="mini-player-art">M</div>
|
||||
<div class="mini-player-info">
|
||||
<div class="mini-player-title" id="mini-player-title">Midnight City</div>
|
||||
<div class="mini-player-artist" id="mini-player-artist">M83</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 4px;">
|
||||
<div class="mini-control-btn" onclick="toggleLike(); event.stopPropagation();">
|
||||
<img src="../../icons/heart.tga"/>
|
||||
</div>
|
||||
<div class="mini-control-btn">
|
||||
<div class="mini-control-btn" id="mini-play-btn" onclick="togglePlay(); event.stopPropagation();">
|
||||
<img src="../../icons/play.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -406,11 +438,11 @@
|
||||
<img src="../../icons/home.tga"/>
|
||||
<span>Home</span>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<div class="nav-item" onclick="openSearch()">
|
||||
<img src="../../icons/search.tga"/>
|
||||
<span>Search</span>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<div class="nav-item" onclick="openLibrary()">
|
||||
<img src="../../icons/library.tga"/>
|
||||
<span>Library</span>
|
||||
</div>
|
||||
@@ -3,33 +3,11 @@
|
||||
|
||||
local results = {}
|
||||
local logCounter = 0
|
||||
local currentDocument = nil -- Cache the document reference
|
||||
|
||||
-- Helper to get document (try multiple methods)
|
||||
-- Helper to get document (use global set by C++)
|
||||
local function getDocument()
|
||||
-- First try the cached document
|
||||
if currentDocument then
|
||||
return currentDocument
|
||||
end
|
||||
-- Try the global document
|
||||
if document then
|
||||
currentDocument = document
|
||||
return document
|
||||
end
|
||||
-- Try to get from RmlUi context
|
||||
if rmlui and rmlui.contexts and rmlui.contexts.main then
|
||||
local ctx = rmlui.contexts.main
|
||||
-- documents is a proxy, iterate to get first doc
|
||||
if ctx.documents then
|
||||
for i, doc in ipairs(ctx.documents) do
|
||||
if doc then
|
||||
currentDocument = doc
|
||||
return currentDocument
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
-- The C++ code sets 'document' global after loading
|
||||
return document
|
||||
end
|
||||
|
||||
local function log(msg)
|
||||
@@ -155,7 +133,7 @@ function testCrypto()
|
||||
|
||||
-- Test SHA256
|
||||
if success then
|
||||
local hash = crypto.sha256("hello world")
|
||||
local hash = crypto.hash("sha256", "hello world")
|
||||
if hash then
|
||||
log("SHA256: " .. hash:sub(1, 32) .. "...")
|
||||
else
|
||||
@@ -240,10 +218,6 @@ function testStorage()
|
||||
end
|
||||
end
|
||||
|
||||
-- Initialize: cache the document when script loads
|
||||
if document then
|
||||
currentDocument = document
|
||||
print("[LUA] Document cached on load")
|
||||
end
|
||||
-- Initialize
|
||||
log("Sandbox Test App loaded")
|
||||
log("Lua version: " .. (_VERSION or "unknown"))
|
||||
@@ -6,8 +6,8 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-bar">
|
||||
<div class="app-bar-nav btn-icon" onclick="goBack()">
|
||||
<span class="icon">←</span>
|
||||
<div class="app-bar-nav btn-icon" onclick="goHome()">
|
||||
<span class="icon"><</span>
|
||||
</div>
|
||||
<div class="app-bar-title">Sandbox Test</div>
|
||||
</div>
|
||||
39
base-apps/com.mosis.sandbox-test/main_content.rml
Normal file
39
base-apps/com.mosis.sandbox-test/main_content.rml
Normal file
@@ -0,0 +1,39 @@
|
||||
<!-- Sandbox Test App Content Fragment -->
|
||||
<!-- Styles are in shell.rml -->
|
||||
|
||||
<div class="sandbox-content">
|
||||
<div class="sandbox-header">
|
||||
<span class="sandbox-header-title">Sandbox Test</span>
|
||||
</div>
|
||||
|
||||
<div class="sandbox-body">
|
||||
<div class="sandbox-card">
|
||||
<div class="sandbox-card-title">Timer Test</div>
|
||||
<div id="timer-status" class="sandbox-status">Not started</div>
|
||||
<div class="sandbox-btn" onclick="testSandboxTimer()">Start Timer</div>
|
||||
</div>
|
||||
|
||||
<div class="sandbox-card">
|
||||
<div class="sandbox-card-title">JSON Test</div>
|
||||
<div id="json-status" class="sandbox-status">Not tested</div>
|
||||
<div class="sandbox-btn" onclick="testSandboxJSON()">Test JSON</div>
|
||||
</div>
|
||||
|
||||
<div class="sandbox-card">
|
||||
<div class="sandbox-card-title">Crypto Test</div>
|
||||
<div id="crypto-status" class="sandbox-status">Not tested</div>
|
||||
<div class="sandbox-btn" onclick="testSandboxCrypto()">Test Crypto</div>
|
||||
</div>
|
||||
|
||||
<div class="sandbox-card">
|
||||
<div class="sandbox-card-title">Storage Test</div>
|
||||
<div id="storage-status" class="sandbox-status">Not tested</div>
|
||||
<div class="sandbox-btn" onclick="testSandboxStorage()">Test Storage</div>
|
||||
</div>
|
||||
|
||||
<div class="sandbox-card">
|
||||
<div class="sandbox-card-title">Results</div>
|
||||
<div id="sandbox-results" class="sandbox-results">Click buttons above to run tests</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
16
base-apps/com.mosis.settings/manifest.json
Normal file
16
base-apps/com.mosis.settings/manifest.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "com.mosis.settings",
|
||||
"name": "Settings",
|
||||
"version": "1.0.0",
|
||||
"version_code": 1,
|
||||
"entry": "settings.rml",
|
||||
"icon": "../../icons/settings.tga",
|
||||
"description": "System settings and configuration",
|
||||
"developer": {
|
||||
"name": "Mosis Team",
|
||||
"email": "dev@mosis.dev"
|
||||
},
|
||||
"is_system_app": true,
|
||||
"permissions": [],
|
||||
"min_api_version": 1
|
||||
}
|
||||
288
base-apps/com.mosis.settings/settings.lua
Normal file
288
base-apps/com.mosis.settings/settings.lua
Normal file
@@ -0,0 +1,288 @@
|
||||
-- settings.lua - Settings app functionality
|
||||
-- Handles toggles, navigation, and system settings
|
||||
|
||||
local settings_doc = nil
|
||||
|
||||
-- Settings state
|
||||
local settings_state = {
|
||||
wifi = true,
|
||||
wifi_network = "MosisNetwork",
|
||||
bluetooth = false,
|
||||
airplane_mode = false,
|
||||
location = true,
|
||||
location_mode = "High accuracy",
|
||||
brightness = 80,
|
||||
auto_brightness = true,
|
||||
dark_mode = true,
|
||||
font_size = "Default",
|
||||
sleep_timeout = "5 minutes",
|
||||
sound_volume = 70,
|
||||
ring_volume = 80,
|
||||
vibration = true,
|
||||
dnd = false,
|
||||
battery_percent = 85,
|
||||
battery_status = "Not charging",
|
||||
storage_used = 32,
|
||||
storage_total = 128
|
||||
}
|
||||
|
||||
-- Initialize settings
|
||||
function initSettings(doc)
|
||||
print("[Settings] Initializing...")
|
||||
settings_doc = doc
|
||||
updateAllToggles()
|
||||
updateAllSubtitles()
|
||||
end
|
||||
|
||||
-- Update all toggle states
|
||||
function updateAllToggles()
|
||||
updateToggle("wifi", settings_state.wifi)
|
||||
updateToggle("bluetooth", settings_state.bluetooth)
|
||||
updateToggle("airplane", settings_state.airplane_mode)
|
||||
updateToggle("location", settings_state.location)
|
||||
end
|
||||
|
||||
-- Update a single toggle
|
||||
function updateToggle(name, state)
|
||||
if not settings_doc then return end
|
||||
|
||||
local toggle = settings_doc:GetElementById("toggle-" .. name)
|
||||
if toggle then
|
||||
if state then
|
||||
toggle:SetClass("active", true)
|
||||
else
|
||||
toggle:SetClass("active", false)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Update all subtitles
|
||||
function updateAllSubtitles()
|
||||
if not settings_doc then return end
|
||||
|
||||
-- WiFi
|
||||
local wifi_sub = settings_doc:GetElementById("subtitle-wifi")
|
||||
if wifi_sub then
|
||||
if settings_state.wifi then
|
||||
wifi_sub.inner_rml = "Connected to " .. settings_state.wifi_network
|
||||
else
|
||||
wifi_sub.inner_rml = "Off"
|
||||
end
|
||||
end
|
||||
|
||||
-- Bluetooth
|
||||
local bt_sub = settings_doc:GetElementById("subtitle-bluetooth")
|
||||
if bt_sub then
|
||||
bt_sub.inner_rml = settings_state.bluetooth and "On" or "Off"
|
||||
end
|
||||
|
||||
-- Battery
|
||||
local bat_sub = settings_doc:GetElementById("subtitle-battery")
|
||||
if bat_sub then
|
||||
bat_sub.inner_rml = settings_state.battery_percent .. "% - " .. settings_state.battery_status
|
||||
end
|
||||
|
||||
-- Storage
|
||||
local storage_sub = settings_doc:GetElementById("subtitle-storage")
|
||||
if storage_sub then
|
||||
storage_sub.inner_rml = settings_state.storage_used .. " GB of " .. settings_state.storage_total .. " GB used"
|
||||
end
|
||||
|
||||
-- Location
|
||||
local loc_sub = settings_doc:GetElementById("subtitle-location")
|
||||
if loc_sub then
|
||||
if settings_state.location then
|
||||
loc_sub.inner_rml = "On - " .. settings_state.location_mode
|
||||
else
|
||||
loc_sub.inner_rml = "Off"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Toggle WiFi
|
||||
function toggleWifi()
|
||||
settings_state.wifi = not settings_state.wifi
|
||||
print("[Settings] WiFi: " .. tostring(settings_state.wifi))
|
||||
|
||||
updateToggle("wifi", settings_state.wifi)
|
||||
updateAllSubtitles()
|
||||
|
||||
if showToast then
|
||||
showToast(settings_state.wifi and "WiFi enabled" or "WiFi disabled")
|
||||
end
|
||||
end
|
||||
|
||||
-- Toggle Bluetooth
|
||||
function toggleBluetooth()
|
||||
settings_state.bluetooth = not settings_state.bluetooth
|
||||
print("[Settings] Bluetooth: " .. tostring(settings_state.bluetooth))
|
||||
|
||||
updateToggle("bluetooth", settings_state.bluetooth)
|
||||
updateAllSubtitles()
|
||||
|
||||
if showToast then
|
||||
showToast(settings_state.bluetooth and "Bluetooth enabled" or "Bluetooth disabled")
|
||||
end
|
||||
end
|
||||
|
||||
-- Toggle Airplane Mode
|
||||
function toggleAirplaneMode()
|
||||
settings_state.airplane_mode = not settings_state.airplane_mode
|
||||
print("[Settings] Airplane mode: " .. tostring(settings_state.airplane_mode))
|
||||
|
||||
if settings_state.airplane_mode then
|
||||
-- Disable wireless when airplane mode is on
|
||||
settings_state.wifi = false
|
||||
settings_state.bluetooth = false
|
||||
updateToggle("wifi", false)
|
||||
updateToggle("bluetooth", false)
|
||||
end
|
||||
|
||||
updateToggle("airplane", settings_state.airplane_mode)
|
||||
updateAllSubtitles()
|
||||
|
||||
if showToast then
|
||||
showToast(settings_state.airplane_mode and "Airplane mode on" or "Airplane mode off")
|
||||
end
|
||||
end
|
||||
|
||||
-- Toggle Location
|
||||
function toggleLocation()
|
||||
settings_state.location = not settings_state.location
|
||||
print("[Settings] Location: " .. tostring(settings_state.location))
|
||||
|
||||
updateToggle("location", settings_state.location)
|
||||
updateAllSubtitles()
|
||||
|
||||
if showToast then
|
||||
showToast(settings_state.location and "Location enabled" or "Location disabled")
|
||||
end
|
||||
end
|
||||
|
||||
-- Open WiFi settings
|
||||
function openWifiSettings()
|
||||
print("[Settings] Opening WiFi settings...")
|
||||
if navigateTo then
|
||||
navigateTo("wifi_settings")
|
||||
else
|
||||
if showToast then
|
||||
showToast("WiFi settings")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Open Bluetooth settings
|
||||
function openBluetoothSettings()
|
||||
print("[Settings] Opening Bluetooth settings...")
|
||||
if showToast then
|
||||
showToast("Bluetooth settings")
|
||||
end
|
||||
end
|
||||
|
||||
-- Open Display settings
|
||||
function openDisplaySettings()
|
||||
print("[Settings] Opening Display settings...")
|
||||
if navigateTo then
|
||||
navigateTo("display_settings")
|
||||
else
|
||||
if showToast then
|
||||
showToast("Display settings")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Open Sound settings
|
||||
function openSoundSettings()
|
||||
print("[Settings] Opening Sound settings...")
|
||||
if showToast then
|
||||
showToast("Sound settings")
|
||||
end
|
||||
end
|
||||
|
||||
-- Open Notifications settings
|
||||
function openNotificationsSettings()
|
||||
print("[Settings] Opening Notifications settings...")
|
||||
if showToast then
|
||||
showToast("Notification settings")
|
||||
end
|
||||
end
|
||||
|
||||
-- Open Battery settings
|
||||
function openBatterySettings()
|
||||
print("[Settings] Opening Battery settings...")
|
||||
if showToast then
|
||||
showToast("Battery: " .. settings_state.battery_percent .. "%")
|
||||
end
|
||||
end
|
||||
|
||||
-- Open Storage settings
|
||||
function openStorageSettings()
|
||||
print("[Settings] Opening Storage settings...")
|
||||
if showToast then
|
||||
local used_percent = math.floor(settings_state.storage_used / settings_state.storage_total * 100)
|
||||
showToast("Storage: " .. used_percent .. "% used")
|
||||
end
|
||||
end
|
||||
|
||||
-- Open Lock Screen settings
|
||||
function openLockScreenSettings()
|
||||
print("[Settings] Opening Lock Screen settings...")
|
||||
if showToast then
|
||||
showToast("Lock screen settings")
|
||||
end
|
||||
end
|
||||
|
||||
-- Open Privacy settings
|
||||
function openPrivacySettings()
|
||||
print("[Settings] Opening Privacy settings...")
|
||||
if showToast then
|
||||
showToast("Privacy settings")
|
||||
end
|
||||
end
|
||||
|
||||
-- Open Location settings
|
||||
function openLocationSettings()
|
||||
print("[Settings] Opening Location settings...")
|
||||
if showToast then
|
||||
showToast("Location settings")
|
||||
end
|
||||
end
|
||||
|
||||
-- Open About Phone
|
||||
function openAboutPhone()
|
||||
print("[Settings] Opening About Phone...")
|
||||
if navigateTo then
|
||||
navigateTo("about_phone")
|
||||
else
|
||||
if showToast then
|
||||
showToast("Mosis Virtual Phone v1.0")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Open User Profile
|
||||
function openUserProfile()
|
||||
print("[Settings] Opening User Profile...")
|
||||
if showToast then
|
||||
showToast("User profile")
|
||||
end
|
||||
end
|
||||
|
||||
-- Search settings
|
||||
function searchSettings(query)
|
||||
print("[Settings] Searching: " .. query)
|
||||
-- TODO: Implement settings search
|
||||
end
|
||||
|
||||
-- Get setting value
|
||||
function getSetting(key)
|
||||
return settings_state[key]
|
||||
end
|
||||
|
||||
-- Set setting value
|
||||
function setSetting(key, value)
|
||||
settings_state[key] = value
|
||||
print("[Settings] Set " .. key .. " = " .. tostring(value))
|
||||
updateAllToggles()
|
||||
updateAllSubtitles()
|
||||
end
|
||||
370
base-apps/com.mosis.settings/settings.rml
Normal file
370
base-apps/com.mosis.settings/settings.rml
Normal file
@@ -0,0 +1,370 @@
|
||||
<rml>
|
||||
<head>
|
||||
<link type="text/rcss" href="../../ui/html.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/theme.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/components.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/layout.rcss"/>
|
||||
<script src="../../scripts/navigation.lua"></script>
|
||||
<script src="../../scripts/layout.lua"></script>
|
||||
<script src="settings.lua"></script>
|
||||
<title>Settings</title>
|
||||
<style>
|
||||
.settings-list {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: 8px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
padding: 16px 16px 8px 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: #BB86FC;
|
||||
}
|
||||
|
||||
.settings-item {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
background-color: #1E1E1E;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-item:hover {
|
||||
background-color: #252525;
|
||||
}
|
||||
|
||||
.settings-item:active {
|
||||
background-color: #2A2A2A;
|
||||
}
|
||||
|
||||
.settings-item + .settings-item {
|
||||
border-top: 1px #333333;
|
||||
}
|
||||
|
||||
.settings-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-right: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.settings-icon img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
font-size: 18px;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.settings-subtitle {
|
||||
font-size: 16px;
|
||||
color: #B3B3B3;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.settings-action {
|
||||
font-size: 20px;
|
||||
color: #666666;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.settings-toggle {
|
||||
width: 56px;
|
||||
height: 32px;
|
||||
border-radius: 16px;
|
||||
background-color: #666666;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.settings-toggle:hover {
|
||||
background-color: #777777;
|
||||
}
|
||||
|
||||
.settings-toggle.active {
|
||||
background-color: rgba(187, 134, 252, 0.5);
|
||||
}
|
||||
|
||||
.settings-toggle-thumb {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 14px;
|
||||
background-color: #B3B3B3;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
}
|
||||
|
||||
.settings-toggle.active .settings-toggle-thumb {
|
||||
background-color: #BB86FC;
|
||||
left: 26px;
|
||||
}
|
||||
|
||||
.user-card {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 20px 16px;
|
||||
background-color: #1E1E1E;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-card:hover {
|
||||
background-color: #252525;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 32px;
|
||||
background-color: #BB86FC;
|
||||
margin-right: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
font-size: 16px;
|
||||
color: #B3B3B3;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="app-screen" onload="initLayout(document); initSettings(document)">
|
||||
<!-- System Status Bar -->
|
||||
<div class="system-status-bar">
|
||||
<span id="status-time" class="system-status-time">12:30</span>
|
||||
<div class="system-status-icons">
|
||||
<img src="../../icons/wifi.tga"/>
|
||||
<img src="../../icons/signal.tga"/>
|
||||
<img src="../../icons/battery.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App Bar -->
|
||||
<div class="app-bar">
|
||||
<div class="app-bar-back" onclick="goBack()">
|
||||
<img src="../../icons/back.tga"/>
|
||||
</div>
|
||||
<span class="app-bar-title">Settings</span>
|
||||
<div class="app-bar-actions">
|
||||
<div class="app-bar-action">
|
||||
<img src="../../icons/search.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings List -->
|
||||
<div class="app-content with-nav">
|
||||
<div class="settings-list">
|
||||
<!-- User Card -->
|
||||
<div class="user-card" onclick="openUserProfile()">
|
||||
<div class="user-avatar">U</div>
|
||||
<div class="user-info">
|
||||
<div class="user-name">User</div>
|
||||
<div class="user-email">user@mosis.local</div>
|
||||
</div>
|
||||
<span class="settings-action">></span>
|
||||
</div>
|
||||
|
||||
<!-- Network Section -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-header">Network</div>
|
||||
<div class="settings-item" onclick="openWifiSettings()">
|
||||
<div class="settings-icon">
|
||||
<img src="../../icons/wifi.tga"/>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-title">Wi-Fi</div>
|
||||
<div class="settings-subtitle" id="subtitle-wifi">Connected to MosisNetwork</div>
|
||||
</div>
|
||||
<div id="toggle-wifi" class="settings-toggle active" onclick="toggleWifi(); event.stopPropagation();">
|
||||
<div class="settings-toggle-thumb"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-item" onclick="openBluetoothSettings()">
|
||||
<div class="settings-icon">
|
||||
<img src="../../icons/bluetooth.tga"/>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-title">Bluetooth</div>
|
||||
<div class="settings-subtitle" id="subtitle-bluetooth">Off</div>
|
||||
</div>
|
||||
<div id="toggle-bluetooth" class="settings-toggle" onclick="toggleBluetooth(); event.stopPropagation();">
|
||||
<div class="settings-toggle-thumb"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-item">
|
||||
<div class="settings-icon">
|
||||
<img src="../../icons/airplane.tga"/>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-title">Airplane Mode</div>
|
||||
</div>
|
||||
<div id="toggle-airplane" class="settings-toggle" onclick="toggleAirplaneMode()">
|
||||
<div class="settings-toggle-thumb"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Section -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-header">Device</div>
|
||||
<div class="settings-item" onclick="openDisplaySettings()">
|
||||
<div class="settings-icon">
|
||||
<img src="../../icons/brightness.tga"/>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-title">Display</div>
|
||||
<div class="settings-subtitle">Brightness, wallpaper, sleep</div>
|
||||
</div>
|
||||
<span class="settings-action">></span>
|
||||
</div>
|
||||
<div class="settings-item" onclick="openSoundSettings()">
|
||||
<div class="settings-icon">
|
||||
<img src="../../icons/volume.tga"/>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-title">Sound</div>
|
||||
<div class="settings-subtitle">Volume, ringtone, vibration</div>
|
||||
</div>
|
||||
<span class="settings-action">></span>
|
||||
</div>
|
||||
<div class="settings-item" onclick="openNotificationsSettings()">
|
||||
<div class="settings-icon">
|
||||
<img src="../../icons/notifications.tga"/>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-title">Notifications</div>
|
||||
<div class="settings-subtitle">App notifications, Do not disturb</div>
|
||||
</div>
|
||||
<span class="settings-action">></span>
|
||||
</div>
|
||||
<div class="settings-item" onclick="openBatterySettings()">
|
||||
<div class="settings-icon">
|
||||
<img src="../../icons/battery.tga"/>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-title">Battery</div>
|
||||
<div class="settings-subtitle" id="subtitle-battery">85% - Not charging</div>
|
||||
</div>
|
||||
<span class="settings-action">></span>
|
||||
</div>
|
||||
<div class="settings-item" onclick="openStorageSettings()">
|
||||
<div class="settings-icon">
|
||||
<img src="../../icons/storage.tga"/>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-title">Storage</div>
|
||||
<div class="settings-subtitle" id="subtitle-storage">32 GB of 128 GB used</div>
|
||||
</div>
|
||||
<span class="settings-action">></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Privacy Section -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-header">Privacy & Security</div>
|
||||
<div class="settings-item" onclick="openLockScreenSettings()">
|
||||
<div class="settings-icon">
|
||||
<img src="../../icons/lock.tga"/>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-title">Lock Screen</div>
|
||||
<div class="settings-subtitle">PIN, pattern, fingerprint</div>
|
||||
</div>
|
||||
<span class="settings-action">></span>
|
||||
</div>
|
||||
<div class="settings-item" onclick="openPrivacySettings()">
|
||||
<div class="settings-icon">
|
||||
<img src="../../icons/privacy.tga"/>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-title">Privacy</div>
|
||||
<div class="settings-subtitle">Permissions, account activity</div>
|
||||
</div>
|
||||
<span class="settings-action">></span>
|
||||
</div>
|
||||
<div class="settings-item" onclick="openLocationSettings()">
|
||||
<div class="settings-icon">
|
||||
<img src="../../icons/location.tga"/>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-title">Location</div>
|
||||
<div class="settings-subtitle" id="subtitle-location">On - High accuracy</div>
|
||||
</div>
|
||||
<div id="toggle-location" class="settings-toggle active" onclick="toggleLocation(); event.stopPropagation();">
|
||||
<div class="settings-toggle-thumb"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- About Section -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-header">About</div>
|
||||
<div class="settings-item" onclick="openAboutPhone()">
|
||||
<div class="settings-icon">
|
||||
<img src="../../icons/phone.tga"/>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-title">About Phone</div>
|
||||
<div class="settings-subtitle">Mosis Virtual Phone v1.0</div>
|
||||
</div>
|
||||
<span class="settings-action">></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Navigation Bar -->
|
||||
<div class="system-nav-bar">
|
||||
<div class="system-nav-btn" onclick="onBackPressed()">
|
||||
<img src="../../icons/back.tga"/>
|
||||
</div>
|
||||
<div class="system-nav-home" onclick="onHomePressed()"></div>
|
||||
<div class="system-nav-btn" onclick="onRecentPressed()">
|
||||
<img src="../../icons/menu.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</rml>
|
||||
19
base-apps/com.mosis.store/manifest.json
Normal file
19
base-apps/com.mosis.store/manifest.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"id": "com.mosis.store",
|
||||
"name": "Mosis Store",
|
||||
"version": "1.0.0",
|
||||
"version_code": 1,
|
||||
"entry": "store.rml",
|
||||
"icon": "../../icons/store.tga",
|
||||
"description": "App store for downloading and installing apps",
|
||||
"developer": {
|
||||
"name": "Mosis Team",
|
||||
"email": "dev@mosis.dev"
|
||||
},
|
||||
"is_system_app": true,
|
||||
"permissions": [
|
||||
"network",
|
||||
"storage"
|
||||
],
|
||||
"min_api_version": 1
|
||||
}
|
||||
394
base-apps/com.mosis.store/store.lua
Normal file
394
base-apps/com.mosis.store/store.lua
Normal file
@@ -0,0 +1,394 @@
|
||||
-- store.lua - App Store system app logic
|
||||
-- Milestone 10: Device-Side App Management
|
||||
|
||||
-- State
|
||||
local state = {
|
||||
screen = "home", -- home, games, updates, search, detail
|
||||
installed = {}, -- Installed apps from mosis.apps
|
||||
updates = {}, -- Available updates
|
||||
featured = {}, -- Featured apps from store API
|
||||
categories = {}, -- Category list
|
||||
search_query = "", -- Current search
|
||||
selected_app = nil, -- Selected app for detail view
|
||||
is_loading = false,
|
||||
error_message = nil
|
||||
}
|
||||
|
||||
-- Store API configuration
|
||||
local STORE_API = "https://portal.mosis.dev/store"
|
||||
|
||||
-- ============================================================================
|
||||
-- Initialization
|
||||
-- ============================================================================
|
||||
|
||||
function init()
|
||||
print("[Store] Initializing...")
|
||||
|
||||
-- Load installed apps
|
||||
refreshInstalledApps()
|
||||
|
||||
-- Check for updates
|
||||
checkForUpdates()
|
||||
|
||||
-- Fetch featured apps (async)
|
||||
fetchFeaturedApps()
|
||||
end
|
||||
|
||||
function refreshInstalledApps()
|
||||
if mosis and mosis.apps then
|
||||
state.installed = mosis.apps.getInstalled() or {}
|
||||
print("[Store] Loaded " .. #state.installed .. " installed apps")
|
||||
else
|
||||
print("[Store] Warning: mosis.apps API not available")
|
||||
state.installed = {}
|
||||
end
|
||||
end
|
||||
|
||||
function checkForUpdates()
|
||||
if mosis and mosis.apps then
|
||||
state.updates = mosis.apps.checkUpdates() or {}
|
||||
print("[Store] Found " .. #state.updates .. " updates")
|
||||
updateBadge()
|
||||
end
|
||||
end
|
||||
|
||||
function updateBadge()
|
||||
-- Update the updates tab badge
|
||||
local badge = document:GetElementById("updates-badge")
|
||||
if badge then
|
||||
if #state.updates > 0 then
|
||||
badge.inner_rml = tostring(#state.updates)
|
||||
badge.style.display = "block"
|
||||
else
|
||||
badge.style.display = "none"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- API Calls
|
||||
-- ============================================================================
|
||||
|
||||
function fetchFeaturedApps()
|
||||
state.is_loading = true
|
||||
|
||||
-- TODO: Make HTTP request to STORE_API
|
||||
-- For now, use placeholder data
|
||||
state.featured = {
|
||||
{
|
||||
id = "com.mosis.weather",
|
||||
name = "Weather Pro",
|
||||
category = "Weather",
|
||||
rating = 4.8,
|
||||
downloads = 125000,
|
||||
size = 15728640, -- 15 MB
|
||||
description = "Beautiful forecasts for your virtual world",
|
||||
icon = "W",
|
||||
color = "#2196F3"
|
||||
},
|
||||
{
|
||||
id = "com.mosis.notes",
|
||||
name = "Notes",
|
||||
category = "Productivity",
|
||||
rating = 4.7,
|
||||
downloads = 89000,
|
||||
size = 8388608, -- 8 MB
|
||||
description = "Simple note-taking app",
|
||||
icon = "N",
|
||||
color = "#03DAC6"
|
||||
}
|
||||
}
|
||||
|
||||
state.is_loading = false
|
||||
render()
|
||||
end
|
||||
|
||||
function searchApps(query)
|
||||
state.search_query = query
|
||||
state.screen = "search"
|
||||
|
||||
if query == "" then
|
||||
state.screen = "home"
|
||||
render()
|
||||
return
|
||||
end
|
||||
|
||||
state.is_loading = true
|
||||
render()
|
||||
|
||||
-- TODO: Make HTTP request to STORE_API/search
|
||||
-- For now, filter featured apps
|
||||
local results = {}
|
||||
local lower_query = query:lower()
|
||||
for _, app in ipairs(state.featured) do
|
||||
if app.name:lower():find(lower_query) or
|
||||
app.category:lower():find(lower_query) then
|
||||
table.insert(results, app)
|
||||
end
|
||||
end
|
||||
|
||||
state.search_results = results
|
||||
state.is_loading = false
|
||||
render()
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- Installation
|
||||
-- ============================================================================
|
||||
|
||||
function installApp(app_id, download_url, signature)
|
||||
print("[Store] Installing: " .. app_id)
|
||||
|
||||
showProgress(app_id)
|
||||
|
||||
if mosis and mosis.apps then
|
||||
mosis.apps.install(download_url or "", signature or "", function(progress)
|
||||
updateProgress(progress)
|
||||
|
||||
if progress.stage == "complete" then
|
||||
hideProgress()
|
||||
showToast("App installed successfully!")
|
||||
refreshInstalledApps()
|
||||
render()
|
||||
elseif progress.stage == "failed" then
|
||||
hideProgress()
|
||||
showError("Installation failed: " .. (progress.error or "Unknown error"))
|
||||
end
|
||||
end)
|
||||
else
|
||||
hideProgress()
|
||||
showError("App installation not available")
|
||||
end
|
||||
end
|
||||
|
||||
function uninstallApp(package_id)
|
||||
print("[Store] Uninstalling: " .. package_id)
|
||||
|
||||
if mosis and mosis.apps then
|
||||
local success = mosis.apps.uninstall(package_id)
|
||||
if success then
|
||||
showToast("App uninstalled")
|
||||
refreshInstalledApps()
|
||||
render()
|
||||
else
|
||||
showError("Failed to uninstall app")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function openApp(package_id)
|
||||
print("[Store] Launching: " .. package_id)
|
||||
|
||||
if mosis and mosis.apps then
|
||||
mosis.apps.launch(package_id)
|
||||
end
|
||||
end
|
||||
|
||||
function updateApp(package_id)
|
||||
print("[Store] Updating: " .. package_id)
|
||||
|
||||
-- Find update info
|
||||
for _, update in ipairs(state.updates) do
|
||||
if update.package_id == package_id then
|
||||
installApp(package_id, update.download_url, update.signature)
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
showError("No update available for this app")
|
||||
end
|
||||
|
||||
function updateAllApps()
|
||||
print("[Store] Updating all apps...")
|
||||
|
||||
for _, update in ipairs(state.updates) do
|
||||
-- Queue updates (in a real implementation, this would be sequential)
|
||||
installApp(update.package_id, update.download_url, update.signature)
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- UI Helpers
|
||||
-- ============================================================================
|
||||
|
||||
function isInstalled(package_id)
|
||||
for _, app in ipairs(state.installed) do
|
||||
if app.package_id == package_id then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function hasUpdate(package_id)
|
||||
for _, update in ipairs(state.updates) do
|
||||
if update.package_id == package_id then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function formatSize(bytes)
|
||||
if bytes >= 1048576 then
|
||||
return string.format("%.1f MB", bytes / 1048576)
|
||||
elseif bytes >= 1024 then
|
||||
return string.format("%.0f KB", bytes / 1024)
|
||||
else
|
||||
return bytes .. " B"
|
||||
end
|
||||
end
|
||||
|
||||
function formatDownloads(count)
|
||||
if count >= 1000000 then
|
||||
return string.format("%.1fM", count / 1000000)
|
||||
elseif count >= 1000 then
|
||||
return string.format("%.0fK", count / 1000)
|
||||
else
|
||||
return tostring(count)
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- Progress Dialog
|
||||
-- ============================================================================
|
||||
|
||||
function showProgress(app_name)
|
||||
local dialog = document:GetElementById("progress-dialog")
|
||||
if dialog then
|
||||
dialog.style.display = "flex"
|
||||
local title = document:GetElementById("progress-title")
|
||||
if title then
|
||||
title.inner_rml = "Installing " .. (app_name or "App")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function updateProgress(progress)
|
||||
local bar = document:GetElementById("progress-bar")
|
||||
if bar then
|
||||
bar.style.width = (progress.progress * 100) .. "%"
|
||||
end
|
||||
|
||||
local status = document:GetElementById("progress-status")
|
||||
if status then
|
||||
local stage_names = {
|
||||
downloading = "Downloading...",
|
||||
verifying = "Verifying...",
|
||||
extracting = "Extracting...",
|
||||
registering = "Registering...",
|
||||
complete = "Complete!",
|
||||
failed = "Failed"
|
||||
}
|
||||
status.inner_rml = stage_names[progress.stage] or progress.stage
|
||||
end
|
||||
end
|
||||
|
||||
function hideProgress()
|
||||
local dialog = document:GetElementById("progress-dialog")
|
||||
if dialog then
|
||||
dialog.style.display = "none"
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- Toast/Error Messages
|
||||
-- ============================================================================
|
||||
|
||||
function showToast(message)
|
||||
local toast = document:GetElementById("toast")
|
||||
if toast then
|
||||
toast.inner_rml = message
|
||||
toast.style.display = "block"
|
||||
-- Auto-hide after 3 seconds (would need timer API)
|
||||
end
|
||||
print("[Store] Toast: " .. message)
|
||||
end
|
||||
|
||||
function showError(message)
|
||||
state.error_message = message
|
||||
local error_el = document:GetElementById("error-dialog")
|
||||
if error_el then
|
||||
local msg = document:GetElementById("error-message")
|
||||
if msg then
|
||||
msg.inner_rml = message
|
||||
end
|
||||
error_el.style.display = "flex"
|
||||
end
|
||||
print("[Store] Error: " .. message)
|
||||
end
|
||||
|
||||
function hideError()
|
||||
state.error_message = nil
|
||||
local error_el = document:GetElementById("error-dialog")
|
||||
if error_el then
|
||||
error_el.style.display = "none"
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- Navigation
|
||||
-- ============================================================================
|
||||
|
||||
function showHome()
|
||||
state.screen = "home"
|
||||
setActiveTab("apps")
|
||||
render()
|
||||
end
|
||||
|
||||
function showGames()
|
||||
state.screen = "games"
|
||||
setActiveTab("games")
|
||||
render()
|
||||
end
|
||||
|
||||
function showUpdates()
|
||||
state.screen = "updates"
|
||||
setActiveTab("updates")
|
||||
checkForUpdates()
|
||||
render()
|
||||
end
|
||||
|
||||
function showSearch()
|
||||
state.screen = "search"
|
||||
render()
|
||||
end
|
||||
|
||||
function showAppDetail(app_id)
|
||||
state.screen = "detail"
|
||||
-- Find app in featured or installed
|
||||
for _, app in ipairs(state.featured) do
|
||||
if app.id == app_id then
|
||||
state.selected_app = app
|
||||
break
|
||||
end
|
||||
end
|
||||
render()
|
||||
end
|
||||
|
||||
function setActiveTab(tab)
|
||||
local tabs = {"apps", "games", "updates"}
|
||||
for _, t in ipairs(tabs) do
|
||||
local el = document:GetElementById("nav-" .. t)
|
||||
if el then
|
||||
if t == tab then
|
||||
el:SetClass("active", true)
|
||||
else
|
||||
el:SetClass("active", false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- Rendering
|
||||
-- ============================================================================
|
||||
|
||||
function render()
|
||||
-- The RML is mostly static with dynamic data binding
|
||||
-- In a full implementation, we'd update innerHTML of content areas
|
||||
print("[Store] Rendering screen: " .. state.screen)
|
||||
end
|
||||
|
||||
-- Initialize on load
|
||||
init()
|
||||
@@ -3,25 +3,18 @@
|
||||
<link type="text/rcss" href="../../ui/html.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/theme.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/components.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/layout.rcss"/>
|
||||
<script src="../../scripts/navigation.lua"></script>
|
||||
<script src="../../scripts/layout.lua"></script>
|
||||
<script src="store.lua"></script>
|
||||
<title>Store</title>
|
||||
<style>
|
||||
.store-screen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #121212;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.store-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Search Bar */
|
||||
.store-search {
|
||||
margin: 16px;
|
||||
background-color: #2D2D2D;
|
||||
@@ -29,11 +22,16 @@
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.store-search:hover {
|
||||
background-color: #3D3D3D;
|
||||
}
|
||||
|
||||
.store-search img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 12px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
@@ -43,7 +41,6 @@
|
||||
color: #B3B3B3;
|
||||
}
|
||||
|
||||
/* Section Headers */
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -58,43 +55,46 @@
|
||||
}
|
||||
|
||||
.section-action {
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
color: #BB86FC;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Featured Banner */
|
||||
.featured-banner {
|
||||
margin: 0 16px 16px 16px;
|
||||
height: 160px;
|
||||
background: linear-gradient(135deg, #BB86FC 0%, #6200EE 100%);
|
||||
height: 140px;
|
||||
background-color: #7C3AED;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.featured-banner:hover {
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.featured-tag {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
color: rgba(255,255,255,0.7);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.featured-title {
|
||||
font-size: 24px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.featured-subtitle {
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
color: rgba(255,255,255,0.8);
|
||||
}
|
||||
|
||||
/* App Cards Row */
|
||||
.app-cards-row {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
@@ -103,7 +103,7 @@
|
||||
}
|
||||
|
||||
.app-card {
|
||||
min-width: 140px;
|
||||
min-width: 130px;
|
||||
background-color: #1E1E1E;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
@@ -115,44 +115,35 @@
|
||||
}
|
||||
|
||||
.app-card-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
font-size: 24px;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.app-card-name {
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #FFFFFF;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.app-card-category {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
color: #B3B3B3;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.app-card-rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
color: #B3B3B3;
|
||||
}
|
||||
|
||||
.app-card-rating img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* App List Items */
|
||||
.app-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -187,35 +178,18 @@
|
||||
}
|
||||
|
||||
.app-list-meta {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
color: #B3B3B3;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.app-list-rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.app-list-rating img {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.app-list-rating span {
|
||||
font-size: 16px;
|
||||
color: #B3B3B3;
|
||||
}
|
||||
|
||||
.install-btn {
|
||||
background-color: #BB86FC;
|
||||
color: #000000;
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 10px 22px;
|
||||
border-radius: 22px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -226,10 +200,8 @@
|
||||
.install-btn.installed {
|
||||
background-color: transparent;
|
||||
color: #BB86FC;
|
||||
border: 1px solid #BB86FC;
|
||||
}
|
||||
|
||||
/* Category Chips */
|
||||
.category-chips {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
@@ -257,7 +229,6 @@
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/* Bottom Nav */
|
||||
.store-bottom-nav {
|
||||
display: flex;
|
||||
height: 56px;
|
||||
@@ -274,21 +245,24 @@
|
||||
color: #B3B3B3;
|
||||
}
|
||||
|
||||
.store-nav-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.store-nav-item.active {
|
||||
color: #BB86FC;
|
||||
}
|
||||
|
||||
.store-nav-item img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.store-nav-item span {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Color palette for app icons */
|
||||
.bg-purple { background-color: #BB86FC; }
|
||||
.bg-teal { background-color: #03DAC6; }
|
||||
.bg-orange { background-color: #FF9800; }
|
||||
@@ -297,132 +271,29 @@
|
||||
.bg-red { background-color: #F44336; }
|
||||
.bg-pink { background-color: #E91E63; }
|
||||
.bg-indigo { background-color: #3F51B5; }
|
||||
|
||||
/* Dialog Overlay */
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background-color: #2D2D2D;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
min-width: 280px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dialog-message {
|
||||
font-size: 16px;
|
||||
color: #B3B3B3;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dialog-status {
|
||||
font-size: 14px;
|
||||
color: #B3B3B3;
|
||||
margin-top: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dialog-btn {
|
||||
background-color: transparent;
|
||||
color: #BB86FC;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dialog-btn:hover {
|
||||
background-color: rgba(187, 134, 252, 0.1);
|
||||
}
|
||||
|
||||
/* Progress Bar */
|
||||
.progress-container {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background-color: #1E1E1E;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background-color: #BB86FC;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: #323232;
|
||||
color: #FFFFFF;
|
||||
font-size: 14px;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
/* Badge */
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
background-color: #F44336;
|
||||
color: #FFFFFF;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.store-nav-item {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="store-screen">
|
||||
<body class="app-screen" onload="initLayout(document)">
|
||||
<!-- System Status Bar -->
|
||||
<div class="system-status-bar">
|
||||
<span id="status-time" class="system-status-time">12:30</span>
|
||||
<div class="system-status-icons">
|
||||
<img src="../../icons/wifi.tga"/>
|
||||
<img src="../../icons/signal.tga"/>
|
||||
<img src="../../icons/battery.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App Bar -->
|
||||
<div class="app-bar">
|
||||
<div class="app-bar-nav btn-icon" onclick="goBack()">
|
||||
<img src="../../icons/back.tga" style="width: 32px; height: 32px;"/>
|
||||
<div class="app-bar-back" onclick="goBack()">
|
||||
<img src="../../icons/back.tga"/>
|
||||
</div>
|
||||
<span class="app-bar-title">Mosis Store</span>
|
||||
<div class="btn-icon">
|
||||
<img src="../../icons/account.tga" style="width: 32px; height: 32px;"/>
|
||||
<div class="app-bar-actions">
|
||||
<div class="app-bar-action">
|
||||
<img src="../../icons/account.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -447,7 +318,6 @@
|
||||
<div class="category-chip">Games</div>
|
||||
<div class="category-chip">Social</div>
|
||||
<div class="category-chip">Productivity</div>
|
||||
<div class="category-chip">Entertainment</div>
|
||||
<div class="category-chip">Tools</div>
|
||||
</div>
|
||||
|
||||
@@ -490,15 +360,11 @@
|
||||
<span class="section-action">See all</span>
|
||||
</div>
|
||||
|
||||
<!-- App List -->
|
||||
<div class="app-list-item">
|
||||
<div class="app-list-icon bg-purple">S</div>
|
||||
<div class="app-list-info">
|
||||
<div class="app-list-name">Social Hub</div>
|
||||
<div class="app-list-meta">Social • 12 MB</div>
|
||||
<div class="app-list-rating">
|
||||
<span>4.9 • 1.2M downloads</span>
|
||||
</div>
|
||||
<div class="app-list-meta">Social - 12 MB - 4.9</div>
|
||||
</div>
|
||||
<div class="install-btn">Install</div>
|
||||
</div>
|
||||
@@ -507,10 +373,7 @@
|
||||
<div class="app-list-icon bg-red">G</div>
|
||||
<div class="app-list-info">
|
||||
<div class="app-list-name">Games Center</div>
|
||||
<div class="app-list-meta">Games • 45 MB</div>
|
||||
<div class="app-list-rating">
|
||||
<span>4.7 • 890K downloads</span>
|
||||
</div>
|
||||
<div class="app-list-meta">Games - 45 MB - 4.7</div>
|
||||
</div>
|
||||
<div class="install-btn">Install</div>
|
||||
</div>
|
||||
@@ -519,10 +382,7 @@
|
||||
<div class="app-list-icon bg-indigo">F</div>
|
||||
<div class="app-list-info">
|
||||
<div class="app-list-name">File Manager</div>
|
||||
<div class="app-list-meta">Tools • 8 MB</div>
|
||||
<div class="app-list-rating">
|
||||
<span>4.6 • 650K downloads</span>
|
||||
</div>
|
||||
<div class="app-list-meta">Tools - 8 MB - 4.6</div>
|
||||
</div>
|
||||
<div class="install-btn installed">Open</div>
|
||||
</div>
|
||||
@@ -531,100 +391,26 @@
|
||||
<div class="app-list-icon bg-pink">M</div>
|
||||
<div class="app-list-info">
|
||||
<div class="app-list-name">Music Player</div>
|
||||
<div class="app-list-meta">Music • 18 MB</div>
|
||||
<div class="app-list-rating">
|
||||
<span>4.5 • 520K downloads</span>
|
||||
</div>
|
||||
<div class="app-list-meta">Music - 18 MB - 4.5</div>
|
||||
</div>
|
||||
<div class="install-btn">Install</div>
|
||||
</div>
|
||||
|
||||
<div class="app-list-item">
|
||||
<div class="app-list-icon bg-teal">P</div>
|
||||
<div class="app-list-info">
|
||||
<div class="app-list-name">Photo Editor</div>
|
||||
<div class="app-list-meta">Photography • 32 MB</div>
|
||||
<div class="app-list-rating">
|
||||
<span>4.4 • 410K downloads</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="install-btn">Install</div>
|
||||
</div>
|
||||
|
||||
<!-- New Games Section -->
|
||||
<div class="section-header">
|
||||
<span class="section-title">New Games</span>
|
||||
<span class="section-action">See all</span>
|
||||
</div>
|
||||
|
||||
<div class="app-cards-row">
|
||||
<div class="app-card">
|
||||
<div class="app-card-icon bg-red">P</div>
|
||||
<div class="app-card-name">Puzzle Quest</div>
|
||||
<div class="app-card-category">Puzzle</div>
|
||||
<div class="app-card-rating">4.8</div>
|
||||
</div>
|
||||
<div class="app-card">
|
||||
<div class="app-card-icon bg-green">R</div>
|
||||
<div class="app-card-name">Racing VR</div>
|
||||
<div class="app-card-category">Racing</div>
|
||||
<div class="app-card-rating">4.6</div>
|
||||
</div>
|
||||
<div class="app-card">
|
||||
<div class="app-card-icon bg-blue">S</div>
|
||||
<div class="app-card-name">Space Explorer</div>
|
||||
<div class="app-card-category">Adventure</div>
|
||||
<div class="app-card-rating">4.7</div>
|
||||
</div>
|
||||
<div class="app-card">
|
||||
<div class="app-card-icon bg-orange">C</div>
|
||||
<div class="app-card-name">Card Master</div>
|
||||
<div class="app-card-category">Card</div>
|
||||
<div class="app-card-rating">4.5</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Navigation -->
|
||||
<div class="store-bottom-nav">
|
||||
<div id="nav-apps" class="store-nav-item active" onclick="showHome()">
|
||||
<div class="store-nav-item active">
|
||||
<img src="../../icons/home.tga"/>
|
||||
<span>Apps</span>
|
||||
</div>
|
||||
<div id="nav-games" class="store-nav-item" onclick="showGames()">
|
||||
<div class="store-nav-item">
|
||||
<img src="../../icons/game.tga"/>
|
||||
<span>Games</span>
|
||||
</div>
|
||||
<div id="nav-updates" class="store-nav-item" onclick="showUpdates()">
|
||||
<div class="store-nav-item">
|
||||
<img src="../../icons/download.tga"/>
|
||||
<span>Updates</span>
|
||||
<div id="updates-badge" class="badge" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Dialog (hidden by default) -->
|
||||
<div id="progress-dialog" class="dialog-overlay" style="display: none;">
|
||||
<div class="dialog">
|
||||
<div id="progress-title" class="dialog-title">Installing...</div>
|
||||
<div class="progress-container">
|
||||
<div id="progress-bar" class="progress-bar" style="width: 0%;"></div>
|
||||
</div>
|
||||
<div id="progress-status" class="dialog-status">Preparing...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Dialog (hidden by default) -->
|
||||
<div id="error-dialog" class="dialog-overlay" style="display: none;">
|
||||
<div class="dialog">
|
||||
<div class="dialog-title">Error</div>
|
||||
<div id="error-message" class="dialog-message"></div>
|
||||
<div class="dialog-actions">
|
||||
<div class="dialog-btn" onclick="hideError()">OK</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast (hidden by default) -->
|
||||
<div id="toast" class="toast" style="display: none;"></div>
|
||||
</body>
|
||||
</rml>
|
||||
BIN
base-apps/icons/account.tga
LFS
Normal file
BIN
base-apps/icons/account.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/add.tga
LFS
Normal file
BIN
base-apps/icons/add.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/back.tga
LFS
Normal file
BIN
base-apps/icons/back.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/backspace.tga
LFS
Normal file
BIN
base-apps/icons/backspace.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/battery.tga
LFS
Normal file
BIN
base-apps/icons/battery.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/browser.tga
LFS
Normal file
BIN
base-apps/icons/browser.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/calculator.tga
LFS
Normal file
BIN
base-apps/icons/calculator.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/calendar.tga
LFS
Normal file
BIN
base-apps/icons/calendar.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/call_small.tga
LFS
Normal file
BIN
base-apps/icons/call_small.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/camera.tga
LFS
Normal file
BIN
base-apps/icons/camera.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/clock.tga
LFS
Normal file
BIN
base-apps/icons/clock.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/close.tga
LFS
Normal file
BIN
base-apps/icons/close.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/contact_phone.tga
LFS
Normal file
BIN
base-apps/icons/contact_phone.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/contacts.tga
LFS
Normal file
BIN
base-apps/icons/contacts.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/dialpad.tga
LFS
Normal file
BIN
base-apps/icons/dialpad.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/download.tga
LFS
Normal file
BIN
base-apps/icons/download.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/files.tga
LFS
Normal file
BIN
base-apps/icons/files.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/flash.tga
LFS
Normal file
BIN
base-apps/icons/flash.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/forward.tga
LFS
Normal file
BIN
base-apps/icons/forward.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/gallery.tga
LFS
Normal file
BIN
base-apps/icons/gallery.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/game.tga
LFS
Normal file
BIN
base-apps/icons/game.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/heart.tga
LFS
Normal file
BIN
base-apps/icons/heart.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/history.tga
LFS
Normal file
BIN
base-apps/icons/history.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/home.tga
LFS
Normal file
BIN
base-apps/icons/home.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/library.tga
LFS
Normal file
BIN
base-apps/icons/library.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/maps.tga
LFS
Normal file
BIN
base-apps/icons/maps.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/menu.tga
LFS
Normal file
BIN
base-apps/icons/menu.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/message.tga
LFS
Normal file
BIN
base-apps/icons/message.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/more.tga
LFS
Normal file
BIN
base-apps/icons/more.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/music.tga
LFS
Normal file
BIN
base-apps/icons/music.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/notes.tga
LFS
Normal file
BIN
base-apps/icons/notes.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/phone.tga
LFS
Normal file
BIN
base-apps/icons/phone.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/play.tga
LFS
Normal file
BIN
base-apps/icons/play.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/refresh.tga
LFS
Normal file
BIN
base-apps/icons/refresh.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/search.tga
LFS
Normal file
BIN
base-apps/icons/search.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/send.tga
LFS
Normal file
BIN
base-apps/icons/send.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/settings.tga
LFS
Normal file
BIN
base-apps/icons/settings.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/signal.tga
LFS
Normal file
BIN
base-apps/icons/signal.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/store.tga
LFS
Normal file
BIN
base-apps/icons/store.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/switch-camera.tga
LFS
Normal file
BIN
base-apps/icons/switch-camera.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/timer.tga
LFS
Normal file
BIN
base-apps/icons/timer.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/weather.tga
LFS
Normal file
BIN
base-apps/icons/weather.tga
LFS
Normal file
Binary file not shown.
BIN
base-apps/icons/wifi.tga
LFS
Normal file
BIN
base-apps/icons/wifi.tga
LFS
Normal file
Binary file not shown.
103
base-apps/scripts/layout.lua
Normal file
103
base-apps/scripts/layout.lua
Normal file
@@ -0,0 +1,103 @@
|
||||
-- Layout System for Virtual Smartphone
|
||||
-- Provides reusable UI component helpers
|
||||
-- Requires navigation.lua to be loaded first
|
||||
|
||||
-- Icon paths (relative to assets/)
|
||||
local ICON_PATH = "../../icons/"
|
||||
|
||||
-- Default icons
|
||||
local icons = {
|
||||
back = ICON_PATH .. "back.tga",
|
||||
home = ICON_PATH .. "home.tga",
|
||||
menu = ICON_PATH .. "menu.tga",
|
||||
search = ICON_PATH .. "search.tga",
|
||||
more = ICON_PATH .. "more.tga",
|
||||
close = ICON_PATH .. "close.tga",
|
||||
wifi = ICON_PATH .. "wifi.tga",
|
||||
signal = ICON_PATH .. "signal.tga",
|
||||
battery = ICON_PATH .. "battery.tga"
|
||||
}
|
||||
|
||||
-- Get current time formatted as HH:MM
|
||||
local function getCurrentTime()
|
||||
-- In sandbox, we might not have os.date, use a default
|
||||
if os and os.date then
|
||||
return os.date("%H:%M")
|
||||
end
|
||||
return "12:30"
|
||||
end
|
||||
|
||||
-- Update status bar time
|
||||
function updateStatusTime(doc)
|
||||
local timeEl = doc:GetElementById("status-time")
|
||||
if timeEl then
|
||||
timeEl.inner_rml = getCurrentTime()
|
||||
end
|
||||
end
|
||||
|
||||
-- Initialize status bar with current time
|
||||
-- Call from document onload
|
||||
function initStatusBar(doc)
|
||||
updateStatusTime(doc)
|
||||
|
||||
-- Set up timer to update time every minute if timers are available
|
||||
if setTimeout then
|
||||
local function updateLoop()
|
||||
updateStatusTime(doc)
|
||||
setTimeout(updateLoop, 60000)
|
||||
end
|
||||
setTimeout(updateLoop, 60000)
|
||||
end
|
||||
end
|
||||
|
||||
-- Initialize app bar back button
|
||||
-- Call from document onload
|
||||
function initAppBar(doc)
|
||||
local backBtn = doc:GetElementById("app-bar-back")
|
||||
if backBtn then
|
||||
-- Back button is handled via onclick in RML
|
||||
-- This is for any additional setup
|
||||
end
|
||||
end
|
||||
|
||||
-- Initialize system navigation bar
|
||||
-- Call from document onload
|
||||
function initSystemNav(doc)
|
||||
-- Navigation buttons are handled via onclick in RML
|
||||
-- This is for any additional setup
|
||||
end
|
||||
|
||||
-- Full layout initialization
|
||||
-- Call from document onload: initLayout(document)
|
||||
function initLayout(doc)
|
||||
initStatusBar(doc)
|
||||
initAppBar(doc)
|
||||
initSystemNav(doc)
|
||||
print("Layout initialized")
|
||||
end
|
||||
|
||||
-- Handle back button press (for app bar or system nav)
|
||||
function onBackPressed()
|
||||
if canGoBack and canGoBack() then
|
||||
goBack()
|
||||
else
|
||||
-- If at root, go home
|
||||
if goHome then
|
||||
goHome()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Handle home button press
|
||||
function onHomePressed()
|
||||
if goHome then
|
||||
goHome()
|
||||
end
|
||||
end
|
||||
|
||||
-- Handle recent apps button press (placeholder)
|
||||
function onRecentPressed()
|
||||
print("Recent apps pressed (not implemented)")
|
||||
end
|
||||
|
||||
print("Layout system loaded")
|
||||
145
base-apps/scripts/navigation.lua
Normal file
145
base-apps/scripts/navigation.lua
Normal file
@@ -0,0 +1,145 @@
|
||||
-- Navigation System for Virtual Smartphone
|
||||
-- Handles screen transitions and state management
|
||||
|
||||
-- Screen registry - maps screen names to RML file paths
|
||||
local screens = {
|
||||
home = "apps/home/home.rml",
|
||||
lock = "apps/home/lock.rml",
|
||||
dialer = "apps/dialer/dialer.rml",
|
||||
calling = "apps/dialer/calling.rml",
|
||||
contacts = "apps/contacts/contacts.rml",
|
||||
contact_detail = "apps/contacts/contact_detail.rml",
|
||||
messages = "apps/messages/messages.rml",
|
||||
chat = "apps/messages/chat.rml",
|
||||
settings = "apps/settings/settings.rml",
|
||||
browser = "apps/browser/browser.rml",
|
||||
store = "apps/store/store.rml",
|
||||
camera = "apps/camera/camera.rml",
|
||||
music = "apps/music/music.rml"
|
||||
}
|
||||
|
||||
-- Use global state to persist across document loads
|
||||
-- Initialize only if not already set
|
||||
if not _G.nav_state then
|
||||
_G.nav_state = {
|
||||
history = {},
|
||||
current_screen = "home",
|
||||
nav_direction = "none" -- "forward", "back", "home", "none"
|
||||
}
|
||||
end
|
||||
|
||||
-- Local references for convenience
|
||||
local history = _G.nav_state.history
|
||||
local function get_current() return _G.nav_state.current_screen end
|
||||
local function set_current(s) _G.nav_state.current_screen = s end
|
||||
local function get_direction() return _G.nav_state.nav_direction end
|
||||
local function set_direction(d) _G.nav_state.nav_direction = d end
|
||||
|
||||
-- Apply animation class based on navigation direction
|
||||
local function applyNavAnimation()
|
||||
local dir = get_direction()
|
||||
print("Applying animation, direction: " .. dir)
|
||||
if dir ~= "none" and document then
|
||||
-- In RmlUi Lua, get body element
|
||||
local body = document.body
|
||||
if body then
|
||||
print("Found body element, setting class nav-" .. dir)
|
||||
-- Set the appropriate animation class
|
||||
if dir == "forward" then
|
||||
body:SetClass("nav-forward", true)
|
||||
elseif dir == "back" then
|
||||
body:SetClass("nav-back", true)
|
||||
elseif dir == "home" then
|
||||
body:SetClass("nav-home", true)
|
||||
else
|
||||
body:SetClass("nav-default", true)
|
||||
end
|
||||
else
|
||||
print("Body element not found!")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Navigate to a screen by name
|
||||
function navigateTo(screen_name)
|
||||
print("navigateTo called with: " .. tostring(screen_name))
|
||||
local path = screens[screen_name]
|
||||
if path then
|
||||
-- Push current screen to history before navigating
|
||||
table.insert(history, get_current())
|
||||
set_current(screen_name)
|
||||
set_direction("forward")
|
||||
|
||||
-- Load the new screen using C++ function
|
||||
local success = loadScreen(path)
|
||||
if success then
|
||||
applyNavAnimation()
|
||||
print("Navigated to: " .. screen_name .. " (history depth: " .. #history .. ")")
|
||||
else
|
||||
-- Restore previous state on failure
|
||||
set_current(table.remove(history))
|
||||
print("Failed to navigate to: " .. screen_name)
|
||||
end
|
||||
return success
|
||||
else
|
||||
print("Unknown screen: " .. screen_name)
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
-- Go back to previous screen
|
||||
function goBack()
|
||||
print("goBack called (history depth: " .. #history .. ")")
|
||||
if #history > 0 then
|
||||
local previous = table.remove(history)
|
||||
local path = screens[previous]
|
||||
if path then
|
||||
set_current(previous)
|
||||
set_direction("back")
|
||||
loadScreen(path)
|
||||
applyNavAnimation()
|
||||
print("Back to: " .. previous)
|
||||
return true
|
||||
end
|
||||
else
|
||||
print("No history to go back to")
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Go to home screen (clear history)
|
||||
function goHome()
|
||||
-- Clear the history table
|
||||
for i = #history, 1, -1 do
|
||||
history[i] = nil
|
||||
end
|
||||
set_current("home")
|
||||
set_direction("home")
|
||||
loadScreen(screens.home)
|
||||
applyNavAnimation()
|
||||
print("Navigated to home")
|
||||
end
|
||||
|
||||
-- Get current screen name
|
||||
function getCurrentScreen()
|
||||
return get_current()
|
||||
end
|
||||
|
||||
-- Check if we can go back
|
||||
function canGoBack()
|
||||
return #history > 0
|
||||
end
|
||||
|
||||
-- Clear navigation history
|
||||
function clearHistory()
|
||||
for i = #history, 1, -1 do
|
||||
history[i] = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- Get history depth
|
||||
function getHistoryDepth()
|
||||
return #history
|
||||
end
|
||||
|
||||
print("Navigation system initialized (current: " .. get_current() .. ", history: " .. #history .. ")")
|
||||
1485
base-apps/ui/components.rcss
Normal file
1485
base-apps/ui/components.rcss
Normal file
File diff suppressed because it is too large
Load Diff
93
base-apps/ui/html.rcss
Normal file
93
base-apps/ui/html.rcss
Normal file
@@ -0,0 +1,93 @@
|
||||
body, div,
|
||||
h1, h2, h3, h4,
|
||||
h5, h6, p,
|
||||
hr, pre,
|
||||
tabset tabs
|
||||
{
|
||||
display: block;
|
||||
}
|
||||
|
||||
h1
|
||||
{
|
||||
font-size: 2em;
|
||||
margin: .67em 0;
|
||||
}
|
||||
|
||||
h2
|
||||
{
|
||||
font-size: 1.5em;
|
||||
margin: .75em 0;
|
||||
}
|
||||
|
||||
h3
|
||||
{
|
||||
font-size: 1.17em;
|
||||
margin: .83em 0;
|
||||
}
|
||||
|
||||
h4, p
|
||||
{
|
||||
margin: 1.12em 0;
|
||||
}
|
||||
|
||||
h5
|
||||
{
|
||||
font-size: .83em;
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
|
||||
h6
|
||||
{
|
||||
font-size: .75em;
|
||||
margin: 1.67em 0;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4,
|
||||
h5, h6, strong
|
||||
{
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
em
|
||||
{
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
pre
|
||||
{
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
hr
|
||||
{
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
table
|
||||
{
|
||||
box-sizing: border-box;
|
||||
display: table;
|
||||
}
|
||||
tr
|
||||
{
|
||||
box-sizing: border-box;
|
||||
display: table-row;
|
||||
}
|
||||
td
|
||||
{
|
||||
box-sizing: border-box;
|
||||
display: table-cell;
|
||||
}
|
||||
col
|
||||
{
|
||||
box-sizing: border-box;
|
||||
display: table-column;
|
||||
}
|
||||
colgroup
|
||||
{
|
||||
display: table-column-group;
|
||||
}
|
||||
thead, tbody, tfoot
|
||||
{
|
||||
display: table-row-group;
|
||||
}
|
||||
270
base-apps/ui/layout.rcss
Normal file
270
base-apps/ui/layout.rcss
Normal file
@@ -0,0 +1,270 @@
|
||||
/* ==============================================
|
||||
Layout Components: Reusable App Layout Structure
|
||||
System status bar, app bar, navigation bar
|
||||
============================================== */
|
||||
|
||||
/* ============== System Status Bar ============== */
|
||||
/* Top bar showing time, signal, wifi, battery */
|
||||
|
||||
.system-status-bar {
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
background-color: transparent;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
color: #FFFFFF;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.system-status-bar.bg-surface {
|
||||
background-color: #1E1E1E;
|
||||
}
|
||||
|
||||
.system-status-time {
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.system-status-icons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.system-status-icons img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ============== App Bar ============== */
|
||||
/* Title bar with back button and optional actions */
|
||||
|
||||
.app-bar {
|
||||
height: 72px;
|
||||
padding: 0 8px;
|
||||
background-color: #1E1E1E;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 900;
|
||||
}
|
||||
|
||||
.app-bar.transparent {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.app-bar.primary {
|
||||
background-color: #121212;
|
||||
}
|
||||
|
||||
.app-bar-back {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
||||
.app-bar-back:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.app-bar-back:active {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.app-bar-back img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-bar-title {
|
||||
flex: 1;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
color: #FFFFFF;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.app-bar-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.app-bar-action {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
||||
.app-bar-action:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.app-bar-action:active {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.app-bar-action img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ============== System Navigation Bar ============== */
|
||||
/* Bottom bar with back, home, and recent buttons */
|
||||
|
||||
.system-nav-bar {
|
||||
height: 56px;
|
||||
background-color: #0A0A0A;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.system-nav-bar.transparent {
|
||||
background-color: rgba(10, 10, 10, 0.9);
|
||||
}
|
||||
|
||||
.system-nav-btn {
|
||||
width: 72px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.system-nav-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.system-nav-btn:active {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.system-nav-btn img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
pointer-events: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Home button - pill shape */
|
||||
.system-nav-home {
|
||||
width: 96px;
|
||||
height: 8px;
|
||||
background-color: #FFFFFF;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.system-nav-home:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.system-nav-home:active {
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
/* ============== Screen Layout ============== */
|
||||
/* Standard app screen structure */
|
||||
|
||||
.app-screen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #121212;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Content padding for nav bar */
|
||||
.app-content.with-nav {
|
||||
padding-bottom: 56px;
|
||||
}
|
||||
|
||||
/* Content padding for dock */
|
||||
.app-content.with-dock {
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
/* ============== Combined Header ============== */
|
||||
/* Status bar + App bar combined */
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #1E1E1E;
|
||||
}
|
||||
|
||||
.app-header.transparent {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.app-header .system-status-bar {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* ============== Notification Badge ============== */
|
||||
|
||||
.notification-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
background-color: #CF6679;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
/* ============== Recording Indicator ============== */
|
||||
/* Shown when camera/mic is active */
|
||||
|
||||
.recording-indicator {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: #F44336;
|
||||
border-radius: 6px;
|
||||
animation: recording-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes recording-pulse {
|
||||
0%, 100% { opacity: 1.0; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* ============== Divider ============== */
|
||||
|
||||
.header-divider {
|
||||
height: 1px;
|
||||
background-color: #333333;
|
||||
}
|
||||
333
base-apps/ui/theme.rcss
Normal file
333
base-apps/ui/theme.rcss
Normal file
@@ -0,0 +1,333 @@
|
||||
/* ==============================================
|
||||
Theme: Material Dark for Virtual Smartphone (VR Optimized)
|
||||
All sizes increased for VR readability and raycast interaction
|
||||
============================================== */
|
||||
|
||||
/* Base body styling */
|
||||
body {
|
||||
font-family: LatoLatin;
|
||||
background-color: #121212;
|
||||
color: #FFFFFF;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
animation: 0.2s cubic-out fade-in;
|
||||
}
|
||||
|
||||
/* ============== Typography (VR-sized) ============== */
|
||||
|
||||
.text-h1 {
|
||||
font-size: 120px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.text-h2 {
|
||||
font-size: 80px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.text-h3 {
|
||||
font-size: 64px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.text-h4 {
|
||||
font-size: 48px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.text-h5 {
|
||||
font-size: 32px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.text-h6 {
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.text-body1 {
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.text-body2 {
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.text-caption {
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.text-overline {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
/* ============== Text Colors ============== */
|
||||
|
||||
.text-primary {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: #B3B3B3;
|
||||
}
|
||||
|
||||
.text-disabled {
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.text-accent {
|
||||
color: #BB86FC;
|
||||
}
|
||||
|
||||
.text-accent-secondary {
|
||||
color: #03DAC6;
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: #CF6679;
|
||||
}
|
||||
|
||||
/* ============== Background Colors ============== */
|
||||
|
||||
.bg-primary {
|
||||
background-color: #121212;
|
||||
}
|
||||
|
||||
.bg-surface {
|
||||
background-color: #1E1E1E;
|
||||
}
|
||||
|
||||
.bg-surface-variant {
|
||||
background-color: #2D2D2D;
|
||||
}
|
||||
|
||||
.bg-accent {
|
||||
background-color: #BB86FC;
|
||||
}
|
||||
|
||||
.bg-accent-secondary {
|
||||
background-color: #03DAC6;
|
||||
}
|
||||
|
||||
.bg-error {
|
||||
background-color: #CF6679;
|
||||
}
|
||||
|
||||
/* Hover highlight color - used for interactive element feedback */
|
||||
.bg-hover {
|
||||
background-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
/* ============== Spacing Utilities (VR-scaled) ============== */
|
||||
|
||||
.p-0 { padding: 0; }
|
||||
.p-1 { padding: 6px; }
|
||||
.p-2 { padding: 12px; }
|
||||
.p-3 { padding: 18px; }
|
||||
.p-4 { padding: 24px; }
|
||||
.p-5 { padding: 36px; }
|
||||
.p-6 { padding: 48px; }
|
||||
.p-8 { padding: 72px; }
|
||||
|
||||
.m-0 { margin: 0; }
|
||||
.m-1 { margin: 6px; }
|
||||
.m-2 { margin: 12px; }
|
||||
.m-3 { margin: 18px; }
|
||||
.m-4 { margin: 24px; }
|
||||
.m-5 { margin: 36px; }
|
||||
.m-6 { margin: 48px; }
|
||||
.m-8 { margin: 72px; }
|
||||
|
||||
.mt-1 { margin-top: 6px; }
|
||||
.mt-2 { margin-top: 12px; }
|
||||
.mt-3 { margin-top: 18px; }
|
||||
.mt-4 { margin-top: 24px; }
|
||||
|
||||
.mb-1 { margin-bottom: 6px; }
|
||||
.mb-2 { margin-bottom: 12px; }
|
||||
.mb-3 { margin-bottom: 18px; }
|
||||
.mb-4 { margin-bottom: 24px; }
|
||||
|
||||
/* ============== Layout Utilities ============== */
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.flex-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.flex-around {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.h-full {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ============== Border Utilities ============== */
|
||||
|
||||
.rounded {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.rounded-lg {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.rounded-xl {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.rounded-full {
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
/* ============== Screen Structure ============== */
|
||||
|
||||
.screen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #121212;
|
||||
}
|
||||
|
||||
.screen-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* ============== Animations ============== */
|
||||
|
||||
@keyframes fade-in {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-in-right {
|
||||
0% { transform: translateX(100px); opacity: 0; }
|
||||
100% { transform: translateX(0px); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-in-left {
|
||||
0% { transform: translateX(-100px); opacity: 0; }
|
||||
100% { transform: translateX(0px); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
0% { transform: translateY(50px); opacity: 0; }
|
||||
100% { transform: translateY(0px); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes scale-in {
|
||||
0% { transform: scale(0.9); opacity: 0; }
|
||||
100% { transform: scale(1.0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Hover highlight animation */
|
||||
@keyframes hover-pulse {
|
||||
0% { background-color: rgba(255, 255, 255, 0.0); }
|
||||
50% { background-color: rgba(255, 255, 255, 0.15); }
|
||||
100% { background-color: rgba(255, 255, 255, 0.1); }
|
||||
}
|
||||
|
||||
/* Screen transition classes */
|
||||
.nav-forward {
|
||||
animation: 0.2s cubic-out slide-in-right;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
animation: 0.2s cubic-out slide-in-left;
|
||||
}
|
||||
|
||||
.nav-home {
|
||||
animation: 0.2s back-out scale-in;
|
||||
}
|
||||
|
||||
.nav-default {
|
||||
animation: 0.15s cubic-out fade-in;
|
||||
}
|
||||
|
||||
/* Animation utility classes */
|
||||
.animate-fade-in {
|
||||
animation: 0.2s cubic-out fade-in;
|
||||
}
|
||||
|
||||
.animate-slide-right {
|
||||
animation: 0.25s cubic-out slide-in-right;
|
||||
}
|
||||
|
||||
.animate-slide-left {
|
||||
animation: 0.25s cubic-out slide-in-left;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: 0.2s cubic-out slide-up;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: 0.2s back-out scale-in;
|
||||
}
|
||||
|
||||
/* ============== Interactive Base Class ============== */
|
||||
/* All interactive elements should use cursor: pointer and have hover feedback */
|
||||
|
||||
.interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.interactive:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.interactive:active {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
#include <lua.hpp>
|
||||
#include <algorithm>
|
||||
#ifdef __ANDROID__
|
||||
#include <android/log.h>
|
||||
#define TIMER_LOG(...) __android_log_print(ANDROID_LOG_INFO, "MosisOS", __VA_ARGS__)
|
||||
#else
|
||||
#include <cstdio>
|
||||
#define TIMER_LOG(...) printf(__VA_ARGS__)
|
||||
#endif
|
||||
|
||||
namespace mosis {
|
||||
|
||||
@@ -64,8 +71,12 @@ TimerId TimerManager::SetInterval(lua_State* L, const std::string& app_id,
|
||||
int callback_ref, int interval_ms) {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
|
||||
TIMER_LOG("[Timer] SetInterval called: app=%s, interval=%dms, ref=%d",
|
||||
app_id.c_str(), interval_ms, callback_ref);
|
||||
|
||||
// Check per-app limit
|
||||
if (m_app_timer_counts[app_id] >= MAX_TIMERS_PER_APP) {
|
||||
TIMER_LOG("[Timer] REJECTED: timer limit exceeded for %s", app_id.c_str());
|
||||
luaL_unref(L, LUA_REGISTRYINDEX, callback_ref);
|
||||
return 0;
|
||||
}
|
||||
@@ -89,6 +100,9 @@ TimerId TimerManager::SetInterval(lua_State* L, const std::string& app_id,
|
||||
m_app_timer_counts[app_id]++;
|
||||
m_app_timer_ids[app_id].insert(timer.id);
|
||||
|
||||
TIMER_LOG("[Timer] Created interval timer id=%llu for %s (total: %zu)",
|
||||
timer.id, app_id.c_str(), m_timers.size());
|
||||
|
||||
return timer.id;
|
||||
}
|
||||
|
||||
@@ -158,9 +172,13 @@ void TimerManager::ClearAppTimers(const std::string& app_id) {
|
||||
|
||||
void TimerManager::FireTimer(Timer& timer) {
|
||||
if (timer.cancelled || timer.callback_ref == LUA_NOREF || !timer.L) {
|
||||
TIMER_LOG("[Timer] FireTimer skipped: cancelled=%d, ref=%d, L=%p",
|
||||
timer.cancelled, timer.callback_ref, timer.L);
|
||||
return;
|
||||
}
|
||||
|
||||
TIMER_LOG("[Timer] Firing timer id=%llu for %s", timer.id, timer.app_id.c_str());
|
||||
|
||||
lua_State* L = timer.L;
|
||||
|
||||
// Get the callback from registry
|
||||
@@ -170,10 +188,15 @@ void TimerManager::FireTimer(Timer& timer) {
|
||||
// Call the callback with protected call
|
||||
int result = lua_pcall(L, 0, 0, 0);
|
||||
if (result != LUA_OK) {
|
||||
// Log error but don't propagate
|
||||
// Log the error
|
||||
const char* err = lua_tostring(L, -1);
|
||||
TIMER_LOG("[Timer] ERROR in callback: %s", err ? err : "(unknown)");
|
||||
lua_pop(L, 1);
|
||||
} else {
|
||||
TIMER_LOG("[Timer] Callback completed successfully");
|
||||
}
|
||||
} else {
|
||||
TIMER_LOG("[Timer] ERROR: callback ref is not a function!");
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
@@ -209,6 +232,12 @@ int TimerManager::ProcessTimers() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log only when we have timers to fire (to avoid spam)
|
||||
if (!to_fire.empty()) {
|
||||
TIMER_LOG("[Timer] ProcessTimers: %zu ready to fire out of %zu total",
|
||||
to_fire.size(), m_timers.size());
|
||||
}
|
||||
}
|
||||
|
||||
// Fire timers outside the lock to allow callbacks to create new timers
|
||||
|
||||
@@ -10,19 +10,20 @@
|
||||
|
||||
using namespace mosis::test;
|
||||
|
||||
// Helper: Scale coordinates from hierarchy (logical) to window (physical) space
|
||||
// Helper: Scale coordinates from hierarchy to phone space
|
||||
// The WindowController expects coordinates in phone space (540x960) and handles DPI scaling internally
|
||||
void ScaleToPhysical(TestContext& ctx, int& x, int& y) {
|
||||
// The hierarchy reports coordinates in RmlUi's logical space
|
||||
// which may be DPI-scaled. We need to convert to physical window coordinates.
|
||||
// The hierarchy reports coordinates in RmlUi's context space (540x960)
|
||||
// which matches the phone resolution. No scaling needed since
|
||||
// WindowController::SendClick handles DPI scaling internally.
|
||||
// Just validate the hierarchy dimensions match expected phone size.
|
||||
int hierarchyWidth = ctx.hierarchy.GetWidth();
|
||||
int hierarchyHeight = ctx.hierarchy.GetHeight();
|
||||
int windowWidth = ctx.window.GetInfo().clientWidth;
|
||||
int windowHeight = ctx.window.GetInfo().clientHeight;
|
||||
|
||||
if (hierarchyWidth > 0 && hierarchyHeight > 0) {
|
||||
x = static_cast<int>(x * static_cast<float>(windowWidth) / hierarchyWidth);
|
||||
y = static_cast<int>(y * static_cast<float>(windowHeight) / hierarchyHeight);
|
||||
if (hierarchyWidth != 540 || hierarchyHeight != 960) {
|
||||
std::cerr << " Warning: Unexpected hierarchy size " << hierarchyWidth << "x" << hierarchyHeight << std::endl;
|
||||
}
|
||||
// Coordinates stay in phone space (540x960) - no scaling needed
|
||||
}
|
||||
|
||||
// Helper: Click on an element found by ID in the hierarchy
|
||||
|
||||
@@ -107,33 +107,47 @@ LPARAM WindowController::PhoneToClientLParam(int phoneX, int phoneY) {
|
||||
bool WindowController::SendMouseDown(int phoneX, int phoneY) {
|
||||
if (!m_hwnd) return false;
|
||||
|
||||
// Convert phone coordinates to client coordinates
|
||||
// Convert phone coordinates to client coordinates (using window scale)
|
||||
int clientX = static_cast<int>(phoneX * m_info.scaleX);
|
||||
int clientY = static_cast<int>(phoneY * m_info.scaleY);
|
||||
|
||||
// Calculate screen coordinates
|
||||
int screenX = m_info.clientX + clientX;
|
||||
int screenY = m_info.clientY + clientY;
|
||||
|
||||
// Get DPI info for debugging
|
||||
UINT dpi = GetDpiForWindow(m_hwnd);
|
||||
|
||||
// Calculate screen coordinates from client position
|
||||
// On DPI-aware systems, Windows APIs return consistent coordinate spaces
|
||||
int screenX = m_info.clientX + clientX;
|
||||
int screenY = m_info.clientY + clientY;
|
||||
|
||||
// Ensure window is foreground before clicking
|
||||
SetForegroundWindow(m_hwnd);
|
||||
Sleep(10); // Small delay
|
||||
Sleep(10);
|
||||
|
||||
// Use SendInput for GLFW compatibility
|
||||
SetCursorPos(screenX, screenY);
|
||||
Sleep(10); // Small delay for cursor move
|
||||
// Get screen dimensions for absolute coordinate conversion
|
||||
int screenWidth = GetSystemMetrics(SM_CXSCREEN);
|
||||
int screenHeight = GetSystemMetrics(SM_CYSCREEN);
|
||||
|
||||
INPUT input = {};
|
||||
input.type = INPUT_MOUSE;
|
||||
input.mi.dwFlags = MOUSEEVENTF_LEFTDOWN;
|
||||
SendInput(1, &input, sizeof(INPUT));
|
||||
// Convert to normalized absolute coordinates (0-65535)
|
||||
LONG absX = static_cast<LONG>((screenX * 65535) / screenWidth);
|
||||
LONG absY = static_cast<LONG>((screenY * 65535) / screenHeight);
|
||||
|
||||
std::cout << "MouseDown at phone(" << phoneX << "," << phoneY << ") -> screen("
|
||||
<< screenX << "," << screenY << ") dpi=" << dpi << std::endl;
|
||||
// Send mouse move then button down
|
||||
INPUT moveInput = {};
|
||||
moveInput.type = INPUT_MOUSE;
|
||||
moveInput.mi.dx = absX;
|
||||
moveInput.mi.dy = absY;
|
||||
moveInput.mi.dwFlags = MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE;
|
||||
SendInput(1, &moveInput, sizeof(INPUT));
|
||||
|
||||
Sleep(20);
|
||||
|
||||
INPUT clickInput = {};
|
||||
clickInput.type = INPUT_MOUSE;
|
||||
clickInput.mi.dwFlags = MOUSEEVENTF_LEFTDOWN;
|
||||
SendInput(1, &clickInput, sizeof(INPUT));
|
||||
|
||||
std::cout << "MouseDown at phone(" << phoneX << "," << phoneY << ") -> client("
|
||||
<< clientX << "," << clientY << ") screen(" << screenX << "," << screenY
|
||||
<< ") dpi=" << dpi << std::endl;
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -226,38 +240,20 @@ bool WindowController::Close() {
|
||||
bool WindowController::ResizeToPhone() {
|
||||
if (!m_hwnd) return false;
|
||||
|
||||
// Get current window rect to preserve position
|
||||
// Skip resizing - the designer creates the window at the correct size
|
||||
// and resizing causes DPI scaling mismatches between window and framebuffer.
|
||||
// Just move window to top-left for consistent test positioning.
|
||||
|
||||
RECT currentRect;
|
||||
::GetWindowRect(m_hwnd, ¤tRect);
|
||||
int currentWidth = currentRect.right - currentRect.left;
|
||||
int currentHeight = currentRect.bottom - currentRect.top;
|
||||
|
||||
// Calculate window size needed for phone-sized client area
|
||||
RECT desiredRect = {0, 0, PHONE_WIDTH, PHONE_HEIGHT};
|
||||
DWORD style = ::GetWindowLong(m_hwnd, GWL_STYLE);
|
||||
DWORD exStyle = ::GetWindowLong(m_hwnd, GWL_EXSTYLE);
|
||||
::AdjustWindowRectEx(&desiredRect, style, FALSE, exStyle);
|
||||
std::cout << "ResizeToPhone: keeping current size " << currentWidth << "x" << currentHeight
|
||||
<< ", moving to (0,0)" << std::endl;
|
||||
|
||||
int newWidth = desiredRect.right - desiredRect.left;
|
||||
int newHeight = desiredRect.bottom - desiredRect.top;
|
||||
|
||||
// Check screen dimensions
|
||||
int screenWidth = ::GetSystemMetrics(SM_CXSCREEN);
|
||||
int screenHeight = ::GetSystemMetrics(SM_CYSCREEN);
|
||||
|
||||
std::cout << "ResizeToPhone: screen=" << screenWidth << "x" << screenHeight
|
||||
<< ", needed=" << newWidth << "x" << newHeight << std::endl;
|
||||
|
||||
// If screen is too small, we can't resize to full phone size
|
||||
if (newHeight > screenHeight) {
|
||||
std::cout << " Warning: Screen too small for full phone resolution, keeping current size" << std::endl;
|
||||
return true; // Not an error, just can't resize
|
||||
}
|
||||
|
||||
// Move window to top-left to ensure it fits on screen
|
||||
int newX = 0;
|
||||
int newY = 0;
|
||||
|
||||
std::cout << " Moving to (" << newX << "," << newY << ") size " << newWidth << "x" << newHeight << std::endl;
|
||||
::MoveWindow(m_hwnd, newX, newY, newWidth, newHeight, TRUE);
|
||||
// Move window to top-left for consistent positioning
|
||||
::MoveWindow(m_hwnd, 0, 0, currentWidth, currentHeight, TRUE);
|
||||
|
||||
// Re-acquire window info
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
@@ -274,26 +270,36 @@ bool WindowController::SendClickFromBottom(int clientX, int offsetFromBottom) {
|
||||
int screenX = m_info.clientX + clientX;
|
||||
int screenY = m_info.clientY + clientY;
|
||||
|
||||
// Save current cursor position
|
||||
POINT oldPos;
|
||||
GetCursorPos(&oldPos);
|
||||
|
||||
// Ensure window is active
|
||||
SetForegroundWindow(m_hwnd);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
|
||||
// Move cursor and click
|
||||
SetCursorPos(screenX, screenY);
|
||||
// Get screen dimensions for absolute coordinate conversion
|
||||
int screenWidth = GetSystemMetrics(SM_CXSCREEN);
|
||||
int screenHeight = GetSystemMetrics(SM_CYSCREEN);
|
||||
|
||||
INPUT input = {};
|
||||
input.type = INPUT_MOUSE;
|
||||
input.mi.dwFlags = MOUSEEVENTF_LEFTDOWN;
|
||||
SendInput(1, &input, sizeof(INPUT));
|
||||
// Convert to normalized absolute coordinates (0-65535)
|
||||
LONG absX = static_cast<LONG>((screenX * 65535) / screenWidth);
|
||||
LONG absY = static_cast<LONG>((screenY * 65535) / screenHeight);
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
// Use SendInput with absolute move + click
|
||||
INPUT inputs[3] = {};
|
||||
|
||||
input.mi.dwFlags = MOUSEEVENTF_LEFTUP;
|
||||
SendInput(1, &input, sizeof(INPUT));
|
||||
// Move cursor
|
||||
inputs[0].type = INPUT_MOUSE;
|
||||
inputs[0].mi.dx = absX;
|
||||
inputs[0].mi.dy = absY;
|
||||
inputs[0].mi.dwFlags = MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE;
|
||||
|
||||
// Mouse down
|
||||
inputs[1].type = INPUT_MOUSE;
|
||||
inputs[1].mi.dwFlags = MOUSEEVENTF_LEFTDOWN;
|
||||
|
||||
// Mouse up
|
||||
inputs[2].type = INPUT_MOUSE;
|
||||
inputs[2].mi.dwFlags = MOUSEEVENTF_LEFTUP;
|
||||
|
||||
SendInput(3, inputs, sizeof(INPUT));
|
||||
|
||||
std::cout << "ClickFromBottom at client(" << clientX << "," << clientY
|
||||
<< ") -> screen(" << screenX << "," << screenY << ")" << std::endl;
|
||||
|
||||
@@ -18,7 +18,7 @@ include(FetchContent)
|
||||
FetchContent_Declare(
|
||||
rmlui
|
||||
GIT_REPOSITORY https://github.com/mikke89/RmlUi.git
|
||||
GIT_TAG 6.0
|
||||
GIT_TAG 6.2
|
||||
)
|
||||
set(RMLUI_LUA_BINDINGS ON CACHE BOOL "" FORCE)
|
||||
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
|
||||
|
||||
@@ -16,8 +16,10 @@
|
||||
#include "testing/ui_inspector.h"
|
||||
#include "testing/visual_capture.h"
|
||||
#include <RmlUi/Lua/Interpreter.h>
|
||||
#include <RmlUi/Lua/LuaType.h>
|
||||
#include <iostream>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <chrono>
|
||||
@@ -57,6 +59,11 @@ static std::string g_test_output_path; // Output file for record/screenshot/hie
|
||||
static mosis::testing::ActionRecorder* g_action_recorder = nullptr;
|
||||
static mosis::testing::ActionPlayer* g_action_player = nullptr;
|
||||
|
||||
// Touch simulation state for natural scrolling behavior
|
||||
static bool g_touch_active = false;
|
||||
static int g_touch_start_x = 0;
|
||||
static int g_touch_start_y = 0;
|
||||
|
||||
// Logging and hierarchy dump for testing
|
||||
static std::string g_log_file_path; // Log file path (--log)
|
||||
static std::string g_hierarchy_file_path; // Continuous hierarchy dump path (--hierarchy)
|
||||
@@ -64,8 +71,11 @@ static std::ofstream g_log_file;
|
||||
|
||||
// Simulator mode
|
||||
static bool g_simulator_mode = false;
|
||||
static bool g_shell_mode = false; // Use persistent shell instead of direct documents
|
||||
static std::string g_test_apps_path; // Path to test-apps directory
|
||||
static std::string g_simulator_home_path; // Path to simulator home.rml
|
||||
static std::string g_shell_path; // Path to shell.rml for shell mode
|
||||
static std::string g_main_assets_path; // Path to main assets (for goHome)
|
||||
static std::vector<mosis::AppInfo> g_discovered_apps;
|
||||
static std::string g_current_app_id; // Currently running app (empty = home)
|
||||
|
||||
@@ -76,6 +86,18 @@ bool LoadDocument(const std::string& path);
|
||||
void ReloadDocument();
|
||||
void PopulateSimulatorApps();
|
||||
|
||||
// Helper to set the 'document' global in Lua to the current document
|
||||
static void SetLuaDocumentGlobal(Rml::ElementDocument* doc) {
|
||||
if (!doc) return;
|
||||
lua_State* L = Rml::Lua::Interpreter::GetLuaState();
|
||||
if (!L) return;
|
||||
|
||||
// Push the document using RmlUi's Lua type system
|
||||
Rml::Lua::LuaType<Rml::ElementDocument>::push(L, doc, false);
|
||||
lua_setglobal(L, "document");
|
||||
std::cout << "Set Lua 'document' global to: " << doc->GetTitle() << std::endl;
|
||||
}
|
||||
|
||||
// GLFW callbacks
|
||||
static void ErrorCallback(int error, const char* description) {
|
||||
std::cerr << "GLFW Error " << error << ": " << description << std::endl;
|
||||
@@ -156,10 +178,30 @@ static void MouseButtonCallback(GLFWwindow* window, int button, int action, int
|
||||
double xpos, ypos;
|
||||
glfwGetCursorPos(window, &xpos, &ypos);
|
||||
|
||||
// GLFW cursor callbacks report coordinates in screen/window coordinates (logical pixels)
|
||||
// which match the RmlUi context dimensions, so no scaling needed
|
||||
int mouseX = static_cast<int>(xpos);
|
||||
int mouseY = static_cast<int>(ypos);
|
||||
// glfwGetCursorPos returns position in screen coordinates (same as window size)
|
||||
// which may differ from framebuffer size on high-DPI displays.
|
||||
// We need to scale to match the RmlUi context (which matches framebuffer).
|
||||
int winWidth, winHeight;
|
||||
glfwGetWindowSize(window, &winWidth, &winHeight);
|
||||
|
||||
int fbWidth, fbHeight;
|
||||
glfwGetFramebufferSize(window, &fbWidth, &fbHeight);
|
||||
|
||||
// Scale cursor position: screen coords -> framebuffer coords -> RmlUi context
|
||||
// On high DPI: winWidth=432, fbWidth=540, g_width=540
|
||||
// Cursor in screen space needs to scale to framebuffer/context space
|
||||
int mouseX = static_cast<int>(xpos * fbWidth / winWidth);
|
||||
int mouseY = static_cast<int>(ypos * fbHeight / winHeight);
|
||||
|
||||
// Debug logging for click events
|
||||
std::cout << "MouseButton: " << (action == GLFW_PRESS ? "DOWN" : "UP")
|
||||
<< " at raw(" << xpos << "," << ypos << ") -> scaled(" << mouseX << "," << mouseY << ")"
|
||||
<< " win=" << winWidth << "x" << winHeight << " fb=" << fbWidth << "x" << fbHeight << std::endl;
|
||||
if (g_log_file.is_open()) {
|
||||
g_log_file << "[DEBUG] MouseButton: " << (action == GLFW_PRESS ? "DOWN" : "UP")
|
||||
<< " at raw(" << xpos << "," << ypos << ") -> scaled(" << mouseX << "," << mouseY << ")" << std::endl;
|
||||
g_log_file.flush();
|
||||
}
|
||||
|
||||
int key_modifier = 0;
|
||||
if (mods & GLFW_MOD_CONTROL) key_modifier |= Rml::Input::KM_CTRL;
|
||||
@@ -174,12 +216,20 @@ static void MouseButtonCallback(GLFWwindow* window, int button, int action, int
|
||||
if (g_action_recorder && g_action_recorder->IsRecording()) {
|
||||
g_action_recorder->RecordMouseDown(mouseX, mouseY);
|
||||
}
|
||||
g_context->ProcessMouseButtonDown(0, key_modifier);
|
||||
// Use touch API for natural scrolling behavior
|
||||
g_touch_active = true;
|
||||
g_touch_start_x = mouseX;
|
||||
g_touch_start_y = mouseY;
|
||||
Rml::TouchList touches = {{0, Rml::Vector2f(static_cast<float>(mouseX), static_cast<float>(mouseY))}};
|
||||
g_context->ProcessTouchStart(touches, 0);
|
||||
} else if (action == GLFW_RELEASE) {
|
||||
if (g_action_recorder && g_action_recorder->IsRecording()) {
|
||||
g_action_recorder->RecordMouseUp(mouseX, mouseY);
|
||||
}
|
||||
g_context->ProcessMouseButtonUp(0, key_modifier);
|
||||
// End touch for natural scrolling
|
||||
g_touch_active = false;
|
||||
Rml::TouchList touches = {{0, Rml::Vector2f(static_cast<float>(mouseX), static_cast<float>(mouseY))}};
|
||||
g_context->ProcessTouchEnd(touches, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,12 +237,24 @@ static void MouseButtonCallback(GLFWwindow* window, int button, int action, int
|
||||
static void CursorPosCallback(GLFWwindow* window, double xpos, double ypos) {
|
||||
if (!g_context) return;
|
||||
|
||||
// GLFW cursor callbacks report coordinates in screen/window coordinates (logical pixels)
|
||||
// which match the RmlUi context dimensions, so no scaling needed
|
||||
int mouseX = static_cast<int>(xpos);
|
||||
int mouseY = static_cast<int>(ypos);
|
||||
// Scale from screen coordinates to framebuffer/RmlUi context coordinates
|
||||
int winWidth, winHeight;
|
||||
glfwGetWindowSize(window, &winWidth, &winHeight);
|
||||
|
||||
int fbWidth, fbHeight;
|
||||
glfwGetFramebufferSize(window, &fbWidth, &fbHeight);
|
||||
|
||||
int mouseX = static_cast<int>(xpos * fbWidth / winWidth);
|
||||
int mouseY = static_cast<int>(ypos * fbHeight / winHeight);
|
||||
|
||||
// Always update mouse position for hover effects
|
||||
g_context->ProcessMouseMove(mouseX, mouseY, 0);
|
||||
|
||||
// If touch is active (left button pressed), also send touch move for natural scrolling
|
||||
if (g_touch_active) {
|
||||
Rml::TouchList touches = {{0, Rml::Vector2f(static_cast<float>(mouseX), static_cast<float>(mouseY))}};
|
||||
g_context->ProcessTouchMove(touches, 0);
|
||||
}
|
||||
}
|
||||
|
||||
static void ScrollCallback(GLFWwindow* window, double xoffset, double yoffset) {
|
||||
@@ -245,7 +307,8 @@ static void PrintUsage() {
|
||||
<< " --log FILE Write all log messages to file\n"
|
||||
<< " --hierarchy FILE Continuously dump UI hierarchy to JSON\n"
|
||||
<< "\nSimulator mode:\n"
|
||||
<< " --simulator Run in simulator mode (app launcher)\n"
|
||||
<< " --simulator Run in simulator mode (uses shell by default)\n"
|
||||
<< " --no-shell Disable shell (use direct document loading)\n"
|
||||
<< " --test-apps PATH Path to test-apps directory (default: ./test-apps)\n"
|
||||
<< "\nTest modes:\n"
|
||||
<< " --record FILE Record user actions to JSON file\n"
|
||||
@@ -304,6 +367,9 @@ int main(int argc, char* argv[]) {
|
||||
g_test_output_path = argv[++i];
|
||||
} else if (arg == "--simulator") {
|
||||
g_simulator_mode = true;
|
||||
g_shell_mode = true; // Shell mode is implicit in simulator mode
|
||||
} else if (arg == "--no-shell") {
|
||||
g_shell_mode = false; // Disable shell (use direct document loading)
|
||||
} else if (arg == "--test-apps" && i + 1 < argc) {
|
||||
g_test_apps_path = argv[++i];
|
||||
} else if (arg[0] != '-') {
|
||||
@@ -339,34 +405,42 @@ int main(int argc, char* argv[]) {
|
||||
g_test_apps_path = fs::absolute(g_test_apps_path).string();
|
||||
}
|
||||
|
||||
// Simulator home screen path
|
||||
std::vector<std::string> sim_search_paths = {
|
||||
"simulator/home.rml",
|
||||
"../designer/assets/simulator/home.rml",
|
||||
fs::absolute("simulator/home.rml").string(),
|
||||
// Find main assets for simulator mode
|
||||
// Look for src/main/assets/apps/shell/shell.rml relative to test-apps
|
||||
fs::path test_apps_fs = fs::path(g_test_apps_path);
|
||||
std::vector<fs::path> shell_search_paths = {
|
||||
test_apps_fs.parent_path() / "src" / "main" / "assets" / "apps" / "shell" / "shell.rml",
|
||||
fs::path("src/main/assets/apps/shell/shell.rml"),
|
||||
fs::absolute("src/main/assets/apps/shell/shell.rml"),
|
||||
};
|
||||
for (const auto& path : sim_search_paths) {
|
||||
for (const auto& path : shell_search_paths) {
|
||||
if (fs::exists(path)) {
|
||||
g_simulator_home_path = fs::absolute(path).string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Override document path to simulator home
|
||||
// Override document path to shell
|
||||
if (!g_simulator_home_path.empty()) {
|
||||
document_path = g_simulator_home_path;
|
||||
// Set the main assets path for proper resource loading
|
||||
// g_simulator_home_path is now shell.rml, go up 3 levels to get assets path
|
||||
g_main_assets_path = fs::path(g_simulator_home_path).parent_path().parent_path().parent_path().string();
|
||||
g_shell_path = g_simulator_home_path;
|
||||
document_path = g_shell_path;
|
||||
|
||||
std::cout << "Shell mode enabled" << std::endl;
|
||||
std::cout << "Shell: " << g_shell_path << std::endl;
|
||||
std::cout << "Simulator mode enabled" << std::endl;
|
||||
std::cout << "Test apps path: " << g_test_apps_path << std::endl;
|
||||
std::cout << "Simulator home: " << g_simulator_home_path << std::endl;
|
||||
} else {
|
||||
std::cerr << "Warning: Could not find simulator home.rml" << std::endl;
|
||||
std::cerr << "Warning: Could not find shell.rml for simulator" << std::endl;
|
||||
g_simulator_mode = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Default document
|
||||
if (document_path.empty()) {
|
||||
document_path = "apps/home/home.rml";
|
||||
document_path = "apps/shell/shell.rml";
|
||||
}
|
||||
|
||||
// Derive assets path from document path if not specified
|
||||
@@ -428,6 +502,26 @@ int main(int argc, char* argv[]) {
|
||||
// Make assets_path absolute
|
||||
assets_path = fs::absolute(assets_path).string();
|
||||
std::cout << "Assets path: " << assets_path << std::endl;
|
||||
|
||||
// Determine main assets path (where home.rml lives)
|
||||
// This is important for goHome() when running test apps
|
||||
g_main_assets_path = assets_path;
|
||||
|
||||
// Check if we're running from test-apps folder
|
||||
fs::path assets_fs = fs::path(assets_path);
|
||||
if (assets_fs.filename().string().find("com.") == 0 ||
|
||||
assets_fs.parent_path().filename() == "test-apps") {
|
||||
// Running a test app - find the main assets
|
||||
fs::path test_apps_root = assets_fs.parent_path();
|
||||
if (test_apps_root.filename() != "test-apps") {
|
||||
test_apps_root = test_apps_root.parent_path();
|
||||
}
|
||||
fs::path main_assets = test_apps_root.parent_path() / "src" / "main" / "assets";
|
||||
if (fs::exists(main_assets / "apps" / "shell" / "shell.rml")) {
|
||||
g_main_assets_path = main_assets.string();
|
||||
std::cout << "Main assets: " << g_main_assets_path << std::endl;
|
||||
}
|
||||
}
|
||||
std::cout << "Resolution: " << g_width << "x" << g_height << std::endl;
|
||||
|
||||
// Print mode info
|
||||
@@ -738,6 +832,7 @@ bool InitializeRmlUi(const std::string& assets_path, int fb_width, int fb_height
|
||||
auto* document = g_context->LoadDocument(path);
|
||||
if (document) {
|
||||
document->Show();
|
||||
SetLuaDocumentGlobal(document); // Set 'document' global for Lua scripts
|
||||
g_current_document_path = path;
|
||||
g_current_screen_url = path; // Track current screen for hierarchy dump
|
||||
// Log using RmlUi logging so it appears in log file
|
||||
@@ -750,7 +845,132 @@ bool InitializeRmlUi(const std::string& assets_path, int fb_width, int fb_height
|
||||
return 1;
|
||||
});
|
||||
lua_setglobal(L, "loadScreen");
|
||||
std::cout << "Registered Lua loadScreen function" << std::endl;
|
||||
|
||||
// Register goHome function to return to home screen
|
||||
lua_pushcfunction(L, [](lua_State* L) -> int {
|
||||
if (!g_context) {
|
||||
lua_pushboolean(L, false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::cout << "goHome called - returning to home screen" << std::endl;
|
||||
|
||||
// Reset sandbox back to home context
|
||||
if (g_sandbox && !g_current_app_id.empty()) {
|
||||
g_sandbox->UnregisterAPIs(L);
|
||||
g_sandbox->Reset();
|
||||
|
||||
mosis::DesktopSandboxConfig config;
|
||||
config.app_id = "com.mosis.home";
|
||||
config.data_root = g_main_assets_path + "/sandbox_data";
|
||||
g_sandbox = std::make_unique<mosis::DesktopSandbox>(config);
|
||||
g_sandbox->RegisterAPIs(L);
|
||||
std::cout << "Sandbox reset to home context" << std::endl;
|
||||
}
|
||||
|
||||
// Close existing documents (except debugger)
|
||||
while (g_context->GetNumDocuments() > 1) {
|
||||
auto* doc = g_context->GetDocument(0);
|
||||
if (doc && doc->GetSourceURL().find("__rmlui") == std::string::npos) {
|
||||
doc->Close();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Load shell (which shows home by default)
|
||||
std::string home_path = (fs::path(g_main_assets_path) / "apps" / "shell" / "shell.rml").string();
|
||||
std::cout << "Loading shell from: " << home_path << std::endl;
|
||||
|
||||
auto* document = g_context->LoadDocument(home_path);
|
||||
if (document) {
|
||||
document->Show();
|
||||
SetLuaDocumentGlobal(document); // Set 'document' global for Lua scripts
|
||||
g_current_document_path = home_path;
|
||||
g_current_screen_url = home_path;
|
||||
g_current_app_id = ""; // Clear current app
|
||||
Rml::Log::Message(Rml::Log::LT_INFO, "Returned to home screen");
|
||||
lua_pushboolean(L, true);
|
||||
} else {
|
||||
Rml::Log::Message(Rml::Log::LT_ERROR, "Failed to load home screen from: %s", home_path.c_str());
|
||||
lua_pushboolean(L, false);
|
||||
}
|
||||
return 1;
|
||||
});
|
||||
lua_setglobal(L, "goHome");
|
||||
|
||||
// Register switchAppSandbox function for third-party app launching
|
||||
lua_pushcfunction(L, [](lua_State* L) -> int {
|
||||
const char* app_id = luaL_checkstring(L, 1);
|
||||
const char* install_path = luaL_checkstring(L, 2);
|
||||
|
||||
std::cout << "switchAppSandbox called for: " << app_id << " at " << install_path << std::endl;
|
||||
|
||||
// Store current app ID
|
||||
g_current_app_id = app_id;
|
||||
|
||||
// Reset sandbox for the new app
|
||||
if (g_sandbox) {
|
||||
g_sandbox->UnregisterAPIs(L);
|
||||
g_sandbox->Reset();
|
||||
|
||||
// Re-configure sandbox with app-specific data root
|
||||
mosis::DesktopSandboxConfig config;
|
||||
config.app_id = app_id;
|
||||
config.data_root = std::string(install_path) + "/sandbox_data";
|
||||
g_sandbox = std::make_unique<mosis::DesktopSandbox>(config);
|
||||
g_sandbox->RegisterAPIs(L);
|
||||
std::cout << "Sandbox switched to: " << app_id << std::endl;
|
||||
}
|
||||
|
||||
lua_pushboolean(L, true);
|
||||
return 1;
|
||||
});
|
||||
lua_setglobal(L, "switchAppSandbox");
|
||||
|
||||
// Register loadAppContent function for shell-based app loading
|
||||
// loadAppContent(element, path) - loads RML content into an element's inner_rml
|
||||
lua_pushcfunction(L, [](lua_State* L) -> int {
|
||||
// Get element from first argument (RmlUi element userdata)
|
||||
Rml::Element* element = Rml::Lua::LuaType<Rml::Element>::check(L, 1);
|
||||
const char* path = luaL_checkstring(L, 2);
|
||||
|
||||
if (!element) {
|
||||
std::cerr << "loadAppContent: Invalid element" << std::endl;
|
||||
lua_pushboolean(L, false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Resolve path relative to assets directory
|
||||
std::string full_path;
|
||||
if (fs::path(path).is_absolute()) {
|
||||
full_path = path;
|
||||
} else {
|
||||
full_path = (fs::path(g_main_assets_path) / path).string();
|
||||
}
|
||||
|
||||
// Read file content
|
||||
std::ifstream file(full_path);
|
||||
if (!file.is_open()) {
|
||||
std::cerr << "loadAppContent: Cannot open file: " << full_path << std::endl;
|
||||
lua_pushboolean(L, false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::stringstream buffer;
|
||||
buffer << file.rdbuf();
|
||||
std::string content = buffer.str();
|
||||
|
||||
// Set as inner_rml
|
||||
element->SetInnerRML(content);
|
||||
|
||||
Rml::Log::Message(Rml::Log::LT_INFO, "Loaded app content from: %s", path);
|
||||
lua_pushboolean(L, true);
|
||||
return 1;
|
||||
});
|
||||
lua_setglobal(L, "loadAppContent");
|
||||
|
||||
std::cout << "Registered Lua loadScreen, goHome, switchAppSandbox, and loadAppContent functions" << std::endl;
|
||||
|
||||
// Register simulator API (if in simulator mode)
|
||||
if (g_simulator_mode) {
|
||||
@@ -798,6 +1018,7 @@ bool InitializeRmlUi(const std::string& assets_path, int fb_width, int fb_height
|
||||
auto* document = g_context->LoadDocument(entry);
|
||||
if (document) {
|
||||
document->Show();
|
||||
SetLuaDocumentGlobal(document); // Set 'document' global for Lua scripts
|
||||
g_current_document_path = entry;
|
||||
g_current_screen_url = entry;
|
||||
std::cout << "Simulator: App launched successfully" << std::endl;
|
||||
@@ -842,6 +1063,7 @@ bool InitializeRmlUi(const std::string& assets_path, int fb_width, int fb_height
|
||||
auto* document = g_context->LoadDocument(g_simulator_home_path);
|
||||
if (document) {
|
||||
document->Show();
|
||||
SetLuaDocumentGlobal(document); // Set 'document' global for Lua scripts
|
||||
g_current_document_path = g_simulator_home_path;
|
||||
g_current_screen_url = g_simulator_home_path;
|
||||
|
||||
@@ -913,6 +1135,93 @@ bool InitializeRmlUi(const std::string& assets_path, int fb_width, int fb_height
|
||||
|
||||
lua_setglobal(L, "simulator");
|
||||
std::cout << "Registered simulator Lua API" << std::endl;
|
||||
|
||||
// Also register mosis.apps API for compatibility with home.lua
|
||||
// Create mosis table (or get existing)
|
||||
lua_getglobal(L, "mosis");
|
||||
if (lua_isnil(L, -1)) {
|
||||
lua_pop(L, 1);
|
||||
lua_newtable(L);
|
||||
}
|
||||
|
||||
// Create mosis.apps table
|
||||
lua_newtable(L);
|
||||
|
||||
// mosis.apps.getInstalled() - returns installed apps
|
||||
lua_pushcfunction(L, [](lua_State* L) -> int {
|
||||
// Discover apps if not already done
|
||||
if (g_discovered_apps.empty() && !g_test_apps_path.empty()) {
|
||||
g_discovered_apps = mosis::AppDiscovery::DiscoverApps(g_test_apps_path);
|
||||
std::cout << "Discovered " << g_discovered_apps.size() << " apps in " << g_test_apps_path << std::endl;
|
||||
}
|
||||
|
||||
// Create apps table (array format expected by home.lua)
|
||||
lua_newtable(L);
|
||||
int app_index = 1;
|
||||
for (const auto& app : g_discovered_apps) {
|
||||
lua_pushinteger(L, app_index++);
|
||||
lua_newtable(L);
|
||||
|
||||
lua_pushstring(L, app.name.c_str());
|
||||
lua_setfield(L, -2, "name");
|
||||
|
||||
lua_pushstring(L, app.id.c_str());
|
||||
lua_setfield(L, -2, "package_id");
|
||||
|
||||
lua_pushstring(L, app.GetIconPath().c_str());
|
||||
lua_setfield(L, -2, "icon");
|
||||
|
||||
lua_pushboolean(L, app.is_system_app);
|
||||
lua_setfield(L, -2, "is_system_app");
|
||||
|
||||
lua_pushstring(L, app.app_path.c_str());
|
||||
lua_setfield(L, -2, "install_path");
|
||||
|
||||
lua_pushstring(L, app.entry.c_str());
|
||||
lua_setfield(L, -2, "entry_point");
|
||||
|
||||
lua_settable(L, -3); // apps[index] = app_table
|
||||
}
|
||||
return 1;
|
||||
});
|
||||
lua_setfield(L, -2, "getInstalled");
|
||||
|
||||
// mosis.apps.launch(package_id) - launch an app
|
||||
lua_pushcfunction(L, [](lua_State* L) -> int {
|
||||
const char* package_id = luaL_checkstring(L, 1);
|
||||
|
||||
// Find the app
|
||||
for (const auto& app : g_discovered_apps) {
|
||||
if (app.id == package_id) {
|
||||
std::cout << "mosis.apps.launch: Starting " << package_id << std::endl;
|
||||
g_current_app_id = package_id;
|
||||
|
||||
// Reset sandbox for the new app
|
||||
if (g_sandbox) {
|
||||
g_sandbox->UnregisterAPIs(L);
|
||||
g_sandbox->Reset();
|
||||
|
||||
mosis::DesktopSandboxConfig config;
|
||||
config.app_id = package_id;
|
||||
config.data_root = app.app_path + "/sandbox_data";
|
||||
g_sandbox = std::make_unique<mosis::DesktopSandbox>(config);
|
||||
g_sandbox->RegisterAPIs(L);
|
||||
}
|
||||
|
||||
lua_pushboolean(L, true);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
std::cerr << "mosis.apps.launch: App not found: " << package_id << std::endl;
|
||||
lua_pushboolean(L, false);
|
||||
return 1;
|
||||
});
|
||||
lua_setfield(L, -2, "launch");
|
||||
|
||||
lua_setfield(L, -2, "apps"); // mosis.apps = apps_table
|
||||
lua_setglobal(L, "mosis");
|
||||
std::cout << "Registered mosis.apps Lua API" << std::endl;
|
||||
}
|
||||
|
||||
// Load fonts - search for fonts directory in multiple locations
|
||||
@@ -989,8 +1298,9 @@ bool LoadDocument(const std::string& path) {
|
||||
std::cerr << "Failed to load: " << path << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
document->Show();
|
||||
SetLuaDocumentGlobal(document); // Set 'document' global for Lua scripts
|
||||
g_current_document_path = path;
|
||||
g_current_screen_url = path; // Track current screen for hierarchy dump
|
||||
std::cout << "Loaded: " << path << std::endl;
|
||||
|
||||
@@ -68,6 +68,7 @@ bool AppDiscovery::LoadAppManifest(const std::string& app_directory, AppInfo& in
|
||||
info.version = j.value("version", "1.0.0");
|
||||
info.icon = j.value("icon", "icon.tga");
|
||||
info.description = j.value("description", "");
|
||||
info.is_system_app = j.value("is_system_app", false);
|
||||
|
||||
// Normalize path separators
|
||||
info.app_path = app_directory;
|
||||
|
||||
@@ -11,13 +11,20 @@ struct AppInfo {
|
||||
std::string name; // Display name
|
||||
std::string version; // Version string
|
||||
std::string entry; // Entry point (e.g., "main.rml")
|
||||
std::string icon; // Icon filename (e.g., "icon.tga")
|
||||
std::string icon; // Icon filename (e.g., "icon.tga") or path (e.g., "/system/icons/phone.tga")
|
||||
std::string description; // App description
|
||||
std::string app_path; // Full path to app directory
|
||||
bool is_system_app = false; // True for system apps (com.mosis.*)
|
||||
|
||||
// Computed paths
|
||||
std::string GetEntryPath() const { return app_path + "/" + entry; }
|
||||
std::string GetIconPath() const { return app_path + "/" + icon; }
|
||||
std::string GetIconPath() const {
|
||||
// If icon starts with /, it's an absolute/system path - don't prepend app_path
|
||||
if (!icon.empty() && icon[0] == '/') {
|
||||
return icon;
|
||||
}
|
||||
return app_path + "/" + icon;
|
||||
}
|
||||
};
|
||||
|
||||
class AppDiscovery {
|
||||
|
||||
@@ -19,6 +19,11 @@ void DesktopFileInterface::SetAssetsPath(const std::string& path) {
|
||||
std::string DesktopFileInterface::ResolvePath(const std::string& path) const {
|
||||
std::string resolved = path;
|
||||
|
||||
// Handle file:// URLs
|
||||
if (resolved.rfind("file://", 0) == 0) {
|
||||
resolved = resolved.substr(7); // Strip "file://"
|
||||
}
|
||||
|
||||
// Handle URL-encoded Windows drive letters (D| -> D:)
|
||||
// RmlUi sometimes encodes the colon in Windows paths
|
||||
if (resolved.size() >= 2 && std::isalpha(resolved[0]) && resolved[1] == '|') {
|
||||
|
||||
386
docs/BASE_APPS.md
Normal file
386
docs/BASE_APPS.md
Normal file
@@ -0,0 +1,386 @@
|
||||
# Base Apps Migration Plan
|
||||
|
||||
Convert system apps from embedded assets to standalone `.mosis` packages in `base-apps/`.
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
### Apps in `src/main/assets/apps/`
|
||||
|
||||
| App | Files | Icon |
|
||||
|-----|-------|------|
|
||||
| home | home.rml, home.lua, lock.rml | home.tga |
|
||||
| dialer | dialer.rml, calling.rml | phone.tga |
|
||||
| contacts | contacts.rml | contacts.tga |
|
||||
| messages | messages.rml | message.tga |
|
||||
| settings | settings.rml | settings.tga |
|
||||
| browser | browser.rml | browser.tga |
|
||||
| camera | camera.rml | camera.tga |
|
||||
| store | store.rml | store.tga |
|
||||
| music | music.rml | music.tga |
|
||||
|
||||
### Shared Dependencies
|
||||
|
||||
All apps currently reference shared resources via relative paths:
|
||||
|
||||
```
|
||||
../../ui/html.rcss - Base HTML element styles
|
||||
../../ui/theme.rcss - Color scheme, typography
|
||||
../../ui/components.rcss - Buttons, cards, lists
|
||||
../../scripts/navigation.lua - Screen navigation system
|
||||
../../icons/*.tga - App and UI icons
|
||||
```
|
||||
|
||||
### Current Navigation Model
|
||||
|
||||
Apps are not truly separate - they're screens loaded via a central navigation system:
|
||||
|
||||
```lua
|
||||
-- navigation.lua
|
||||
local screens = {
|
||||
home = "apps/home/home.rml",
|
||||
dialer = "apps/dialer/dialer.rml",
|
||||
-- etc.
|
||||
}
|
||||
mosis.loadDocument(screens[name])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Target State
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
base-apps/
|
||||
├── com.mosis.home/
|
||||
│ ├── manifest.json
|
||||
│ ├── main.rml
|
||||
│ ├── home.lua
|
||||
│ ├── lock.rml
|
||||
│ ├── icon.tga
|
||||
│ └── styles.rcss
|
||||
├── com.mosis.dialer/
|
||||
│ ├── manifest.json
|
||||
│ ├── main.rml (dialer.rml)
|
||||
│ ├── calling.rml
|
||||
│ ├── icon.tga
|
||||
│ └── styles.rcss
|
||||
├── com.mosis.contacts/
|
||||
├── com.mosis.messages/
|
||||
├── com.mosis.settings/
|
||||
├── com.mosis.browser/
|
||||
├── com.mosis.camera/
|
||||
├── com.mosis.store/
|
||||
├── com.mosis.music/
|
||||
└── package.bat
|
||||
```
|
||||
|
||||
### Package IDs
|
||||
|
||||
| App | Package ID |
|
||||
|-----|------------|
|
||||
| Home | com.mosis.home |
|
||||
| Dialer | com.mosis.dialer |
|
||||
| Contacts | com.mosis.contacts |
|
||||
| Messages | com.mosis.messages |
|
||||
| Settings | com.mosis.settings |
|
||||
| Browser | com.mosis.browser |
|
||||
| Camera | com.mosis.camera |
|
||||
| Store | com.mosis.store |
|
||||
| Music | com.mosis.music |
|
||||
|
||||
---
|
||||
|
||||
## Key Decisions
|
||||
|
||||
### 1. Shared Resources Strategy
|
||||
|
||||
**Decision**: System asset path (`/system/`)
|
||||
|
||||
Apps reference shared resources via a system path that the kernel provides:
|
||||
|
||||
```html
|
||||
<link type="text/rcss" href="/system/ui/theme.rcss"/>
|
||||
<script src="/system/scripts/navigation.lua"></script>
|
||||
```
|
||||
|
||||
The kernel maps `/system/` to `src/main/assets/` (or bundled assets on Android).
|
||||
|
||||
**Rationale**:
|
||||
- Avoids duplicating ~50KB of styles/icons per app
|
||||
- Single source of truth for theming
|
||||
- Apps can still have local overrides
|
||||
|
||||
### 2. Navigation Model
|
||||
|
||||
**Decision**: Hybrid approach
|
||||
|
||||
- **System apps** (home, dialer, contacts, etc.) use shared navigation for seamless transitions
|
||||
- **Third-party apps** launch via `mosis.apps.launch()` with isolated sandbox
|
||||
|
||||
System apps get `is_system_app = true` flag:
|
||||
- Can access `/system/` resources
|
||||
- Share navigation state
|
||||
- Can load each other's screens directly
|
||||
|
||||
### 3. Home App Special Status
|
||||
|
||||
The home app is the launcher/shell:
|
||||
- Always running in background
|
||||
- Provides dock and app grid
|
||||
- Launches other apps via `mosis.apps.launch(package_id)`
|
||||
- Cannot be uninstalled
|
||||
|
||||
---
|
||||
|
||||
## Implementation Tasks
|
||||
|
||||
### Phase 1: Infrastructure
|
||||
|
||||
- [ ] **1.1** Rename `test-apps/` to `base-apps/`
|
||||
- [ ] **1.2** Move `com.mosis.sandbox-test` to `base-apps/`
|
||||
- [ ] **1.3** Implement `/system/` path mapping in kernel
|
||||
- Desktop: Map to `assets/` directory
|
||||
- Android: Map to bundled assets
|
||||
- [ ] **1.4** Add `is_system_app` flag to manifest schema
|
||||
- [ ] **1.5** Update designer `--test-apps` flag to `--apps` for consistency
|
||||
|
||||
### Phase 2: Convert Apps
|
||||
|
||||
For each app:
|
||||
|
||||
- [ ] **2.1** Create package directory in `base-apps/com.mosis.{name}/`
|
||||
- [ ] **2.2** Create `manifest.json` with proper metadata
|
||||
- [ ] **2.3** Copy RML files, rename entry to `main.rml`
|
||||
- [ ] **2.4** Copy app-specific Lua scripts
|
||||
- [ ] **2.5** Copy icon from `icons/{name}.tga` to `icon.tga`
|
||||
- [ ] **2.6** Update resource paths to use `/system/` prefix
|
||||
- [ ] **2.7** Create `styles.rcss` for app-specific styles (extract from inline)
|
||||
|
||||
### Phase 3: Update Home App
|
||||
|
||||
- [ ] **3.1** Update home.lua to use `mosis.apps.launch()` for launching
|
||||
- [ ] **3.2** Keep shared navigation for system app transitions
|
||||
- [ ] **3.3** Display all installed apps (base + third-party) in grid
|
||||
- [ ] **3.4** Update dock icons to launch via package ID
|
||||
|
||||
### Phase 4: Cleanup
|
||||
|
||||
- [ ] **4.1** Remove old `src/main/assets/apps/` directory
|
||||
- [ ] **4.2** Update build scripts to package base-apps
|
||||
- [ ] **4.3** Update Android to include base-apps packages
|
||||
- [ ] **4.4** Update documentation
|
||||
|
||||
---
|
||||
|
||||
## Manifest Examples
|
||||
|
||||
### System App (Dialer)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "com.mosis.dialer",
|
||||
"name": "Phone",
|
||||
"version": "1.0.0",
|
||||
"version_code": 1,
|
||||
"entry": "main.rml",
|
||||
"icon": "icon.tga",
|
||||
"description": "Make and receive phone calls",
|
||||
"developer": {
|
||||
"name": "Mosis Team",
|
||||
"email": "dev@mosis.dev"
|
||||
},
|
||||
"permissions": [
|
||||
"contacts.read"
|
||||
],
|
||||
"is_system_app": true,
|
||||
"min_api_version": 1
|
||||
}
|
||||
```
|
||||
|
||||
### Home App (Launcher)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "com.mosis.home",
|
||||
"name": "Home",
|
||||
"version": "1.0.0",
|
||||
"version_code": 1,
|
||||
"entry": "main.rml",
|
||||
"icon": "icon.tga",
|
||||
"description": "Mosis Home Launcher",
|
||||
"developer": {
|
||||
"name": "Mosis Team",
|
||||
"email": "dev@mosis.dev"
|
||||
},
|
||||
"permissions": [
|
||||
"system.launcher"
|
||||
],
|
||||
"is_system_app": true,
|
||||
"is_launcher": true,
|
||||
"min_api_version": 1
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resource Path Mapping
|
||||
|
||||
### Before (Relative)
|
||||
|
||||
```html
|
||||
<link type="text/rcss" href="../../ui/theme.rcss"/>
|
||||
<script src="../../scripts/navigation.lua"></script>
|
||||
<img src="../../icons/phone.tga"/>
|
||||
```
|
||||
|
||||
### After (System Path)
|
||||
|
||||
```html
|
||||
<link type="text/rcss" href="/system/ui/theme.rcss"/>
|
||||
<script src="/system/scripts/navigation.lua"></script>
|
||||
<img src="/system/icons/phone.tga"/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Designer loads home from `base-apps/com.mosis.home/`
|
||||
- [ ] All base apps appear in home screen grid
|
||||
- [ ] Tapping app icon launches the app
|
||||
- [ ] Navigation between system apps works (back button, dock)
|
||||
- [ ] Third-party apps (sandbox-test) launch in isolated sandbox
|
||||
- [ ] `/system/` paths resolve correctly
|
||||
- [ ] Android build includes all base-apps packages
|
||||
- [ ] Hot-reload still works for development
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Lock screen**: Part of home app or separate `com.mosis.lockscreen`?
|
||||
2. **Calling screen**: Part of dialer or system-level overlay?
|
||||
3. **Notifications**: System-level or per-app handling?
|
||||
4. **Settings persistence**: Shared settings service or per-app?
|
||||
|
||||
---
|
||||
|
||||
## File Changes Summary
|
||||
|
||||
| Action | Path |
|
||||
|--------|------|
|
||||
| Rename | `test-apps/` → `base-apps/` |
|
||||
| Create | `base-apps/com.mosis.{home,dialer,...}/` |
|
||||
| Create | Each app's `manifest.json` |
|
||||
| Move | App RML/Lua files to package dirs |
|
||||
| Update | RML paths from `../../` to `/system/` |
|
||||
| Delete | `src/main/assets/apps/` (after migration) |
|
||||
| Update | Designer command line args |
|
||||
| Update | Android asset bundling |
|
||||
|
||||
---
|
||||
|
||||
## Reusable Layout Components
|
||||
|
||||
New standardized UI components for consistent app layouts. Located in `src/main/assets/ui/layout.rcss` and `src/main/assets/scripts/layout.lua`.
|
||||
|
||||
### Components
|
||||
|
||||
| Component | CSS Class | Description |
|
||||
|-----------|-----------|-------------|
|
||||
| Status Bar | `.system-status-bar` | Top bar with time, wifi, signal, battery |
|
||||
| App Bar | `.app-bar` | Title bar with back button and actions |
|
||||
| System Nav | `.system-nav-bar` | Bottom bar with back, home, recent buttons |
|
||||
| App Screen | `.app-screen` | Standard app screen container |
|
||||
| App Content | `.app-content` | Scrollable content area |
|
||||
|
||||
### Standard App Structure
|
||||
|
||||
```html
|
||||
<body class="app-screen" onload="initLayout(document)">
|
||||
<!-- System Status Bar -->
|
||||
<div class="system-status-bar">
|
||||
<span id="status-time" class="system-status-time">12:30</span>
|
||||
<div class="system-status-icons">
|
||||
<img src="../../icons/wifi.tga"/>
|
||||
<img src="../../icons/signal.tga"/>
|
||||
<img src="../../icons/battery.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App Bar -->
|
||||
<div class="app-bar">
|
||||
<div class="app-bar-back" onclick="goBack()">
|
||||
<img src="../../icons/back.tga"/>
|
||||
</div>
|
||||
<span class="app-bar-title">App Title</span>
|
||||
<div class="app-bar-actions">
|
||||
<div class="app-bar-action">
|
||||
<img src="../../icons/search.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="app-content with-nav">
|
||||
<!-- App content here -->
|
||||
</div>
|
||||
|
||||
<!-- System Navigation Bar -->
|
||||
<div class="system-nav-bar">
|
||||
<div class="system-nav-btn" onclick="onBackPressed()">
|
||||
<img src="../../icons/back.tga"/>
|
||||
</div>
|
||||
<div class="system-nav-home" onclick="onHomePressed()"></div>
|
||||
<div class="system-nav-btn" onclick="onRecentPressed()">
|
||||
<img src="../../icons/menu.tga"/>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
```
|
||||
|
||||
### Required Includes
|
||||
|
||||
```html
|
||||
<head>
|
||||
<link type="text/rcss" href="../../ui/html.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/theme.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/components.rcss"/>
|
||||
<link type="text/rcss" href="../../ui/layout.rcss"/>
|
||||
<script src="../../scripts/navigation.lua"></script>
|
||||
<script src="../../scripts/layout.lua"></script>
|
||||
</head>
|
||||
```
|
||||
|
||||
### CSS Modifiers
|
||||
|
||||
| Class | Description |
|
||||
|-------|-------------|
|
||||
| `.app-content.with-nav` | Adds bottom padding for system nav bar |
|
||||
| `.app-content.with-dock` | Adds bottom padding for dock |
|
||||
| `.app-bar.transparent` | Transparent app bar background |
|
||||
| `.system-status-bar.bg-surface` | Solid surface color background |
|
||||
|
||||
### Lua Functions (layout.lua)
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `initLayout(doc)` | Initialize all layout components |
|
||||
| `initStatusBar(doc)` | Setup status bar time updates |
|
||||
| `onBackPressed()` | Handle back button (goes back or home) |
|
||||
| `onHomePressed()` | Handle home button |
|
||||
| `onRecentPressed()` | Handle recent apps button |
|
||||
|
||||
### Updated Apps
|
||||
|
||||
- [x] Settings - Uses full layout with system nav bar
|
||||
- [x] Contacts - Uses status bar and app bar (has own bottom tabs)
|
||||
- [ ] Other apps - Still using old structure
|
||||
|
||||
---
|
||||
|
||||
*Created: 2025-01-19*
|
||||
*Updated: 2025-01-20*
|
||||
@@ -23,7 +23,7 @@ Mosis is a **virtual smartphone OS** for VR games and applications. It provides
|
||||
| MosisService | ✅ Working | RmlUi rendering, touch input, navigation |
|
||||
| App Management | ✅ Working | Install/uninstall apps, sandbox integration |
|
||||
| Lua Sandbox | ✅ Working | 149 security tests passing |
|
||||
| Desktop Designer | ✅ Working | Hot-reload, hierarchy dump, recording |
|
||||
| Desktop Designer | ✅ Working | Hot-reload, shell mode, hierarchy dump, recording |
|
||||
| Designer Tests | ✅ 5/5 Passing | Navigation tests automated |
|
||||
| MosisVR (Unity) | ✅ Building | OpenGL backend working, Vulkan in progress |
|
||||
| MosisUnreal | ✅ Working | Vulkan texture import via UE5 RHI, phone actor with mesh |
|
||||
@@ -35,6 +35,7 @@ Mosis is a **virtual smartphone OS** for VR games and applications. It provides
|
||||
| Android Service | `src/main/` | Native service running RmlUi renderer |
|
||||
| App Management | `src/main/cpp/apps/` | App install/uninstall/launch with sandbox |
|
||||
| Lua Sandbox | `src/main/cpp/sandbox/` | Per-app Lua isolation (22 modules) |
|
||||
| System Shell | `src/main/assets/apps/shell/` | Persistent status bar, nav bar, overlays |
|
||||
| Desktop Designer | `designer/` | UI development with hot-reload |
|
||||
| Designer Tests | `designer-test/` | Automated UI testing framework |
|
||||
| Sandbox Tests | `sandbox-test/` | Lua sandbox security tests (149 tests) |
|
||||
@@ -48,7 +49,7 @@ All detailed documentation is in `docs/`:
|
||||
|----------|-------------|
|
||||
| [BUILD-COMMANDS.md](BUILD-COMMANDS.md) | Android, Desktop Designer, and test build commands |
|
||||
| [ARCHITECTURE.md](ARCHITECTURE.md) | Native libraries, IPC flow, code structure |
|
||||
| [DESKTOP-DESIGNER.md](DESKTOP-DESIGNER.md) | Hot-reload, recording, key files |
|
||||
| [DESKTOP-DESIGNER.md](DESKTOP-DESIGNER.md) | Shell architecture, hot-reload, recording |
|
||||
| [TESTING-FRAMEWORK.md](TESTING-FRAMEWORK.md) | Automated UI testing, writing tests |
|
||||
| [UI-ASSETS.md](UI-ASSETS.md) | Asset structure, navigation system, element IDs |
|
||||
| [MATERIAL-DESIGN.md](MATERIAL-DESIGN.md) | Icons, MDL components, usage guide |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user