diff --git a/apps/tileclk/ChangeLog b/apps/tileclk/ChangeLog new file mode 100644 index 000000000..5560f00bc --- /dev/null +++ b/apps/tileclk/ChangeLog @@ -0,0 +1 @@ +0.01: New App! diff --git a/apps/tileclk/README.md b/apps/tileclk/README.md new file mode 100644 index 000000000..0a042c9c3 --- /dev/null +++ b/apps/tileclk/README.md @@ -0,0 +1,42 @@ +# Tile Clock + +A tile-based digital clock with animated transitions, customizable borders, and clock info integration. + +## How to Use + +### Basic Display +- The clock shows the current time using animated tiles +- Tiles animate smoothly when digits change +- In 12-hour mode, leading zeros are hidden (e.g., "2:30" instead of "02:30") + +### Seconds Display +- **Static mode**: Seconds always shown/hidden based on settings +- **Dynamic mode**: Seconds appear when unlocked, hide when locked + +### Clock Info Integration +- Tap the seconds area (bottom of screen) to show clock info +- Clock info displays weather, notifications, or other system information +- Tap clock info area to focus it +- When focused and tapped, clock info can perform actions if supported +- Tap main time area to unfocus the clock info +- Tap main time area again to dismiss the clock info + +### Touch Controls +- **Tap seconds area**: Switch to clock info +- **Tap clock info area**: Focus the info panel +- **Tap main time once**: Unfocus the clock info +- **Tap main time again**: Dismiss clock info and return to seconds + +### Settings +Access via Settings app to configure: +- Seconds display mode (show/hide/dynamic) +- Border visibility and color +- Widget display options +- Haptic feedback + +## Features +- Smooth tile animations with color interpolation +- Customizable borders with theme color support +- Persistent user preferences +- Performance optimized for smooth operation +- Integration with Bangle.js clock info system \ No newline at end of file diff --git a/apps/tileclk/app-icon.js b/apps/tileclk/app-icon.js new file mode 100644 index 000000000..87371ace9 --- /dev/null +++ b/apps/tileclk/app-icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEwxH+64A/AH4A/AH4A/AH4A/AH4A/AH4A/AH4AIx4ABBQ+zBYQAFCoYAF2YvPisAio6IgEAkcHgEcCIYDCjkAg8jCAJMIF+mzAAMjAAIvIBQNrQQQRDRgtrBIIvM2cVh0cjgCBFYlksi+Bh0OVwYRGD4YQBF5sOPoYdEskAFYKPBg4vDCIwfDToIvNKgNHMAKPEshbCx4LBL4gRFOgYKBd6ASBJojJCDwYvFCIIDCkYCEF6B9JF4KPJjkAioCBh0jR5xfEDAIvIisVL4gRCAYUjBoICCF6BzBVoiPKCIy/WR41rLYSPKAAuzR6UVhwdEsitBF4MOAYIRJF4YQBF5iECx8jAAOPRYVroFABYdrCIqJBDQQABtYbCF5kOg7YBAQMcg5XEEgKPBkYRFd4MHeAIVBkaPOF4KqBVoUcRYQvFgAvBCIgvBgAvBCoMjCAK/NOogADBpoOBBRQA/AH4A/AH4A/AH4A/AH4A/AH4A/ACgA==")) \ No newline at end of file diff --git a/apps/tileclk/app.js b/apps/tileclk/app.js new file mode 100644 index 000000000..bd3985621 --- /dev/null +++ b/apps/tileclk/app.js @@ -0,0 +1,766 @@ +(function() { + /* Tile Clock with Clock Info Integration - Performance Optimized */ + + // ===== CONSTANTS ===== + const SCALE = 12; + const SEC_SCALE = 6; + const FRAC_STEPS = 5; + const ANIM_DELAY = 16; + const COLOR_INTERP = 0.2; + const GAP = 3; + const MAIN_BORDER_THICKNESS = 2; + const SEC_BORDER_THICKNESS = 1; + + // ===== SCREEN DIMENSIONS ===== + const width = g.getWidth(); + const height = g.getHeight(); + + // ===== SETTINGS ===== + const settings = require('Storage').readJSON("tileclk.json", true) || { + widgets: "show", + seconds: "hide", + borders: true, + borderColor: "theme", + haptics: true + }; + const is12Hour = (require('Storage').readJSON("setting.json", true) || {})['12hour'] || false; + let showSeconds = settings.seconds === "show" || (settings.seconds === "dynamic" && !Bangle.isLocked()); + const showBorders = settings.borders !== false; + + // ===== FLAT STATE VARIABLES (Optimized for access speed) ===== + // Display state + let showingClockInfo = false; + let clockInfoUnfocused = false; + let userPreferClockInfo = false; + let userDismissedClockInfo = false; + + // Animation state + let isDrawing = true; + let isColonDrawn = false; + let isDrawingSeconds = false; + let drawTimeout = null; + let secondsTimeout = null; + + // Time tracking + let lastTime = ""; + let lastSeconds = ""; + + // Clock info menu + let clockInfoMenu = null; + + // ===== STATE PERSISTENCE ===== + function loadDisplayState() { + const state = require('Storage').readJSON("tileclk.state.json", true) || {}; + showingClockInfo = state.showingClockInfo || false; + userPreferClockInfo = state.userPreferClockInfo || false; + userDismissedClockInfo = state.userDismissedClockInfo || false; + } + + function saveDisplayState() { + require('Storage').writeJSON("tileclk.state.json", { + showingClockInfo: showingClockInfo, + userPreferClockInfo: userPreferClockInfo, + userDismissedClockInfo: userDismissedClockInfo + }); + } + + // ===== DIGIT BITMAPS ===== + const digitBitmaps = { + ' ': [0, 0, 0, 0, 0], + '0': [7, 5, 5, 5, 7], + '1': [2, 6, 2, 2, 7], + '2': [7, 1, 7, 4, 7], + '3': [7, 1, 7, 1, 7], + '4': [5, 5, 7, 1, 1], + '5': [7, 4, 7, 1, 7], + '6': [7, 4, 7, 5, 7], + '7': [7, 1, 1, 1, 1], + '8': [7, 5, 7, 5, 7], + '9': [7, 5, 7, 1, 7] + }; + + // ===== CALCULATED CONSTANTS ===== + const digitWidth = 3 * SCALE; + const colonWidth = SCALE; + const secDigitWidth = 3 * SEC_SCALE; + const totalSecWidth = 2 * secDigitWidth + GAP; + const secStartX = (width / 2) - (totalSecWidth / 2); + + // ===== WIDGET OFFSET ===== + const widgetYOffset = (settings.widgets === "hide" || settings.widgets === "swipe") ? -SCALE : 0; + + // ===== BORDER COLOR (Inlined for performance) ===== + const borderColor = settings.borderColor === "theme" || !settings.borderColor ? + g.theme.bgH : g.toColor(settings.borderColor); + + // ===== POSITION CALCULATIONS (Pre-computed for performance) ===== + const positions = { + threeDigit: (() => { + const totalWidth = 3 * digitWidth + colonWidth + 3 * GAP; + const startX = (width - totalWidth) / 2; + return { + colonX: Math.round(startX + digitWidth + GAP), + colonY: Math.round(0.35 * height), + digitX: [ + Math.round(startX), + Math.round(startX + digitWidth + GAP + colonWidth + GAP), + Math.round(startX + 2 * (digitWidth + GAP) + colonWidth + GAP) + ], + digitsY: Math.round(0.41 * height) + }; + })(), + fourDigit: (() => { + const totalWidth = 4 * digitWidth + colonWidth + 4 * GAP; + const startX = (width - totalWidth) / 2; + return { + colonX: Math.round(startX + 2 * (digitWidth + GAP)), + colonY: Math.round(0.35 * height), + digitX: [ + Math.round(startX), + Math.round(startX + digitWidth + GAP), + Math.round(startX + 2 * (digitWidth + GAP) + colonWidth + GAP), + Math.round(startX + 3 * (digitWidth + GAP) + colonWidth + GAP) + ], + digitsY: Math.round(0.41 * height) + }; + })(), + seconds: { + x: [Math.round(secStartX), Math.round(secStartX + secDigitWidth + GAP)], + y: Math.round(0.8 * height) + } + }; + + // Pre-compute touch areas for faster access + const mainTimeAreaBottom = widgetYOffset + Math.round(0.6 * height); + const secondsAreaLeft = positions.seconds.x[0] - 10; + const secondsAreaRight = positions.seconds.x[1] + secDigitWidth + 10; + const secondsAreaTop = positions.seconds.y + widgetYOffset - 10; + const secondsAreaBottom = positions.seconds.y + widgetYOffset + 5 * SEC_SCALE + 10; + const clockInfoAreaBottom = positions.seconds.y + widgetYOffset + 40; + + // ===== LAYOUT GENERATION (Optimized with pre-built layouts) ===== + const threeDigitLayout = [ + { type: 'digit', value: 'h2', x: positions.threeDigit.digitX[0], y: positions.threeDigit.digitsY + widgetYOffset, scale: SCALE }, + { type: 'colon', x: positions.threeDigit.colonX, y: positions.threeDigit.colonY + widgetYOffset, scale: SCALE }, + { type: 'digit', value: 'm1', x: positions.threeDigit.digitX[1], y: positions.threeDigit.digitsY + widgetYOffset, scale: SCALE }, + { type: 'digit', value: 'm2', x: positions.threeDigit.digitX[2], y: positions.threeDigit.digitsY + widgetYOffset, scale: SCALE } + ]; + + const fourDigitLayout = [ + { type: 'digit', value: 'h1', x: positions.fourDigit.digitX[0], y: positions.fourDigit.digitsY + widgetYOffset, scale: SCALE }, + { type: 'digit', value: 'h2', x: positions.fourDigit.digitX[1], y: positions.fourDigit.digitsY + widgetYOffset, scale: SCALE }, + { type: 'colon', x: positions.fourDigit.colonX, y: positions.fourDigit.colonY + widgetYOffset, scale: SCALE }, + { type: 'digit', value: 'm1', x: positions.fourDigit.digitX[2], y: positions.fourDigit.digitsY + widgetYOffset, scale: SCALE }, + { type: 'digit', value: 'm2', x: positions.fourDigit.digitX[3], y: positions.fourDigit.digitsY + widgetYOffset, scale: SCALE } + ]; + + // ===== COLOR INTERPOLATION WITH CACHING ===== + const colorCache = {}; + + function interpColor(c1, c2, fraction) { + const key = c1 + "_" + c2 + "_" + Math.round(fraction * FRAC_STEPS); + if (colorCache[key]) return colorCache[key]; + + const r1 = (c1 >> 16) & 0xFF; + const g1 = (c1 >> 8) & 0xFF; + const b1 = c1 & 0xFF; + const r2 = (c2 >> 16) & 0xFF; + const g2 = (c2 >> 8) & 0xFF; + const b2 = c2 & 0xFF; + + const r = Math.round(r1 * (1 - fraction) + r2 * fraction); + const g = Math.round(g1 * (1 - fraction) + g2 * fraction); + const b = Math.round(b1 * (1 - fraction) + b2 * fraction); + + colorCache[key] = (r << 16) | (g << 8) | b; + return colorCache[key]; + } + + // ===== BORDER DRAWING (Inlined check for performance) ===== + function drawBorder(x, y, s, thickness) { + if (!showBorders || thickness <= 0) return; + + g.setColor(borderColor); + for (let i = 0; i < thickness; i++) { + g.drawRect(x + i, y + i, x + s - 1 - i, y + s - 1 - i); + } + } + + // ===== ANIMATION FUNCTIONS ===== + function animateTile(x, y, s, on, callback, isMainDigit) { + let progress = on ? 0 : 1; + const step = on ? COLOR_INTERP : -COLOR_INTERP; + const thickness = isMainDigit ? MAIN_BORDER_THICKNESS : SEC_BORDER_THICKNESS; + + function transition() { + if (!isDrawing) return; + g.setColor(interpColor(on ? g.theme.bg : g.theme.fg, on ? g.theme.fg : g.theme.bg, Math.abs(progress - (on ? 0 : 1)))); + g.fillRect(x, y, x + s - 1, y + s - 1); + + progress += step; + if (progress >= 0 && progress <= 1) { + setTimeout(transition, ANIM_DELAY); + } else { + if (on) drawBorder(x, y, s, thickness); + if (callback) callback(); + } + } + + transition(); + } + + // ===== TILE CALCULATION ===== + function calculateTilesToUpdate(x, y, s, currentDigit, prevDigit) { + const current = digitBitmaps[currentDigit] || digitBitmaps[' ']; + const previous = digitBitmaps[prevDigit] || digitBitmaps[' ']; + const tiles = []; + + for (let row = 0; row < 5; row++) { + const diff = current[row] ^ previous[row]; + if (diff === 0) continue; + + for (let col = 0; col < 3; col++) { + if (diff & (1 << (2 - col))) { + tiles.push({ + x: x + col * s, + y: y + row * s, + state: (current[row] >> (2 - col)) & 1 + }); + } + } + } + + return tiles; + } + + // ===== TILE UPDATE ===== + function updateTile(tile, s, skipAnimation, isMainDigit) { + const thickness = isMainDigit ? MAIN_BORDER_THICKNESS : SEC_BORDER_THICKNESS; + + if (tile.state) { + if (skipAnimation) { + g.setColor(g.theme.fg); + g.fillRect(tile.x, tile.y, tile.x + s - 1, tile.y + s - 1); + drawBorder(tile.x, tile.y, s, thickness); + } else { + animateTile(tile.x, tile.y, s, true, null, isMainDigit); + } + } else { + g.setColor(g.theme.bg); + g.fillRect(tile.x, tile.y, tile.x + s - 1, tile.y + s - 1); + } + } + + function updateTiles(tiles, s, callback, skipAnimation, isMainDigit) { + if (!isDrawing || !tiles.length) { + if (callback) callback(); + return; + } + const tile = tiles.shift(); + updateTile(tile, s, skipAnimation, isMainDigit); + setTimeout(() => updateTiles(tiles, s, callback, skipAnimation, isMainDigit), ANIM_DELAY); + } + + // ===== DIGIT DRAWING ===== + function drawDigit(x, y, s, num, prevNum, callback, skipAnimation, isMainDigit) { + if (num === prevNum) { + if (callback) callback(); + return; + } + const tiles = calculateTilesToUpdate(x, y, s, num, prevNum); + updateTiles(tiles, s, callback, skipAnimation, isMainDigit); + } + + function drawColon(x, y, callback) { + if (!isDrawing || isColonDrawn) { + if (callback) callback(); + return; + } + animateTile(x, y + SCALE * 2, SCALE, true, () => { + animateTile(x, y + SCALE * 4, SCALE, true, () => { + isColonDrawn = true; + if (callback) callback(); + }, true); + }, true); + } + + // ===== TIME UPDATE SCHEDULING ===== + function scheduleNextUpdate() { + if (drawTimeout) clearTimeout(drawTimeout); + const now = new Date(); + const msUntilNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds(); + + drawTimeout = setTimeout(updateAndAnimTime, msUntilNextMinute); + } + + // ===== CLEARING FUNCTIONS (Direct callback approach for performance) ===== + function clearColon(callback) { + if (!isColonDrawn) { + if (callback) callback(); + return; + } + const layout = is12Hour && lastTime[0] === '0' ? threeDigitLayout : fourDigitLayout; + const colonItem = layout.find(item => item.type === 'colon'); + + if (colonItem) { + animateTile(colonItem.x, colonItem.y + SCALE * 2, SCALE, false, () => { + animateTile(colonItem.x, colonItem.y + SCALE * 4, SCALE, false, () => { + isColonDrawn = false; + if (callback) callback(); + }, true); + }, true); + } else { + isColonDrawn = false; + if (callback) callback(); + } + } + + function clearAllDigits(callback) { + const wasThreeDigit = is12Hour && lastTime[0] === '0'; + const layout = wasThreeDigit ? threeDigitLayout : fourDigitLayout; + + const previousDigits = { + h1: lastTime[0] || ' ', + h2: lastTime[1] || ' ', + m1: lastTime[2] || ' ', + m2: lastTime[3] || ' ' + }; + + // Direct callback chaining for better performance + function clearItems(items, next) { + if (!items.length) { + next(); + return; + } + const item = items.shift(); + + if (item.type === 'digit') { + drawDigit(item.x, item.y, item.scale, " ", previousDigits[item.value], () => clearItems(items, next), false, true); + } else if (item.type === 'colon') { + clearColon(() => clearItems(items, next)); + } + } + + // Clear in order: hours -> colon -> minutes -> seconds + const hourItems = layout.filter(item => item.type === 'digit' && item.value.startsWith('h')); + const colonItems = layout.filter(item => item.type === 'colon'); + const minuteItems = layout.filter(item => item.type === 'digit' && item.value.startsWith('m')); + + clearItems(hourItems.slice(), () => { + clearItems(colonItems.slice(), () => { + clearItems(minuteItems.slice(), () => { + if (showSeconds && !showingClockInfo) { + clearSeconds(() => { + lastTime = ""; + if (callback) callback(); + }); + } else { + lastTime = ""; + if (callback) callback(); + } + }); + }); + }); + } + + // ===== MAIN TIME UPDATE (Optimized with direct logic) ===== + function updateAndAnimTime() { + if (!isDrawing) return; + + const now = new Date(); + const hours = (is12Hour ? now.getHours() % 12 || 12 : now.getHours()).toString().padStart(2, '0'); + const minutes = now.getMinutes().toString().padStart(2, '0'); + const currentTime = hours + minutes; + const isCurrentThreeDigit = is12Hour && hours[0] === '0'; + const wasLastThreeDigit = is12Hour && lastTime[0] === '0'; + + function drawTime() { + const currentDigits = { h1: hours[0], h2: hours[1], m1: minutes[0], m2: minutes[1] }; + const previousDigits = (isCurrentThreeDigit !== wasLastThreeDigit && lastTime !== "") ? + { h1: ' ', h2: ' ', m1: ' ', m2: ' ' } : + { h1: lastTime[0] || ' ', h2: lastTime[1] || ' ', m1: lastTime[2] || ' ', m2: lastTime[3] || ' ' }; + + const layout = isCurrentThreeDigit ? threeDigitLayout : fourDigitLayout; + + // Direct drawing without queue abstraction + function drawLayout(items, onComplete) { + if (!items.length) { + if (onComplete) onComplete(); + return; + } + + const item = items.shift(); + const next = () => drawLayout(items, onComplete); + + if (item.type === 'digit') { + drawDigit(item.x, item.y, item.scale, currentDigits[item.value], previousDigits[item.value], next, false, true); + } else if (item.type === 'colon') { + drawColon(item.x, item.y, next); + } + } + + if (isCurrentThreeDigit !== wasLastThreeDigit) { + clearAllDigits(() => drawLayout(layout.slice(), finishDrawing)); + } else { + drawLayout(layout.slice(), finishDrawing); + } + } + + function finishDrawing() { + g.flip(); + lastTime = currentTime; + if (showSeconds && !showingClockInfo) updateSeconds(); + scheduleNextUpdate(); + } + + drawTime(); + } + + // ===== SECONDS HANDLING ===== + function updateSeconds() { + if (isDrawingSeconds || !showSeconds || showingClockInfo) return; + isDrawingSeconds = true; + + const now = new Date(); + const currentMs = now.getMilliseconds(); + let seconds = now.getSeconds(); + + const skipAnimation = lastSeconds === ""; + + if (skipAnimation) { + // Calculate how many tiles need to be drawn from blank + const secondsStr = seconds.toString().padStart(2, '0'); + const tiles0 = calculateTilesToUpdate(positions.seconds.x[0], positions.seconds.y + widgetYOffset, SEC_SCALE, secondsStr[0], ' '); + const tiles1 = calculateTilesToUpdate(positions.seconds.x[1], positions.seconds.y + widgetYOffset, SEC_SCALE, secondsStr[1], ' '); + const tilesNeeded = tiles0.length + tiles1.length; + + // Each tile takes ANIM_DELAY to draw in fast mode + const estimatedDrawTime = tilesNeeded * ANIM_DELAY * 2 // Double for safety margin + const timeUntilNextSecond = 1000 - currentMs; + + // If we can't finish in time, skip to next second + if (estimatedDrawTime > timeUntilNextSecond) { + seconds = (seconds + 1) % 60; + } + } + + seconds = seconds.toString().padStart(2, '0'); + + function updateDigit(index) { + if (seconds[index] !== (lastSeconds[index] || ' ')) { + drawSecondDigit(index, seconds[index], lastSeconds[index] || ' ', () => { + if (index === 0) { + updateDigit(1); + } else { + finishSeconds(); + } + }, skipAnimation); + } else if (index === 0) { + updateDigit(1); + } else { + finishSeconds(); + } + } + + function finishSeconds() { + lastSeconds = seconds; + isDrawingSeconds = false; + g.flip(); + + if (secondsTimeout) clearTimeout(secondsTimeout); + secondsTimeout = setTimeout(() => { + if (showSeconds && !showingClockInfo) updateSeconds(); + }, 1000 - new Date().getMilliseconds()); + } + + updateDigit(0); + } + + function drawSecondDigit(index, digit, prevDigit, callback, skipAnimation) { + drawDigit(positions.seconds.x[index], positions.seconds.y + widgetYOffset, SEC_SCALE, digit, prevDigit, callback, skipAnimation, false); + } + + function clearSeconds(callback) { + if (isDrawingSeconds) { + setTimeout(() => clearSeconds(callback), 50); + return; + } + + isDrawingSeconds = true; + drawDigit(positions.seconds.x[0], positions.seconds.y + widgetYOffset, SEC_SCALE, " ", lastSeconds[0] || ' ', () => { + drawDigit(positions.seconds.x[1], positions.seconds.y + widgetYOffset, SEC_SCALE, " ", lastSeconds[1] || ' ', () => { + lastSeconds = ""; + isDrawingSeconds = false; + if (callback) callback(); + }); + }, false, false); + } + + // ===== SWITCHING FUNCTIONS (Optimized with direct state changes) ===== + function switchToClockInfo() { + if (showingClockInfo || !clockInfoMenu) return; + + if (secondsTimeout) clearTimeout(secondsTimeout); + + const show = () => { + showingClockInfo = true; + clockInfoUnfocused = false; + if (clockInfoMenu) { + clockInfoMenu.focus = true; + clockInfoMenu.redraw(); + } + g.flip(); + }; + + if (showSeconds && lastSeconds !== "") { + clearSeconds(show); + } else { + show(); + } + } + + function hideClockInfo() { + if (!showingClockInfo) return; + + userPreferClockInfo = false; + showingClockInfo = false; + clockInfoUnfocused = false; + + g.setColor(g.theme.bg); + g.fillRect(0, positions.seconds.y + widgetYOffset - 10, width, positions.seconds.y + widgetYOffset + 50); + g.flip(); + } + + function switchToSeconds() { + if (!showingClockInfo || !showSeconds) return; + + userPreferClockInfo = false; + userDismissedClockInfo = true; + showingClockInfo = false; + clockInfoUnfocused = false; + lastSeconds = ""; + + g.setColor(g.theme.bg); + g.fillRect(0, positions.seconds.y + widgetYOffset - 10, width, positions.seconds.y + widgetYOffset + 50); + g.flip(); + + updateAndAnimTime(); + } + + // ===== TOUCH HANDLING (Optimized with pre-computed areas) ===== + function setupTouchHandler() { + Bangle.on("touch", (_, e) => { + if (showingClockInfo) { + // Check if tap is on clock info area + if (e.y >= secondsAreaTop && e.y <= clockInfoAreaBottom) { + // Refocus if unfocused + if (clockInfoUnfocused) { + if (settings.haptics !== false) Bangle.buzz(50); // Haptic feedback for refocus + clockInfoUnfocused = false; + if (clockInfoMenu) { + clockInfoMenu.focus = true; + clockInfoMenu.redraw(); + } + g.flip(); + } + return; + } + + // Check main time area for dismissal + if (e.x >= 0 && e.x <= width && e.y >= widgetYOffset && e.y <= mainTimeAreaBottom) { + if (!clockInfoUnfocused) { + // First tap: unfocus + if (settings.haptics !== false) Bangle.buzz(40); // Light haptic for unfocus + clockInfoUnfocused = true; + if (clockInfoMenu) { + clockInfoMenu.focus = false; + clockInfoMenu.redraw(); + } + g.flip(); + } else { + // Second tap: dismiss + if (settings.haptics !== false) Bangle.buzz(60); // Slightly stronger haptic for dismiss + if (showSeconds) { + userPreferClockInfo = false; + userDismissedClockInfo = true; + switchToSeconds(); + } else { + userDismissedClockInfo = true; + hideClockInfo(); + } + } + } + } else { + // Check seconds area for switching to clock info + if (e.x >= secondsAreaLeft && e.x <= secondsAreaRight && + e.y >= secondsAreaTop && e.y <= secondsAreaBottom) { + if (settings.haptics !== false) Bangle.buzz(50); // Haptic feedback for showing clock info + userPreferClockInfo = true; + userDismissedClockInfo = false; + switchToClockInfo(); + } + } + }); + } + + // ===== CLOCK INFO SETUP ===== + function setupClockInfo() { + if (clockInfoMenu) return; + + try { + const clockInfoItems = require("clock_info").load(); + + const clockInfoDraw = (_, info, options) => { + if (!showingClockInfo) return; + + g.reset().setBgColor(g.theme.bg).setColor(g.theme.fg); + g.clearRect(options.x, options.y, options.x + options.w - 1, options.y + options.h - 1); + + if (!clockInfoUnfocused) { + g.setColor(borderColor); + g.drawRect(options.x, options.y, options.x + options.w - 1, options.y + options.h - 1); + } + + g.setFont("6x8:3"); + + const padding = 8; + const imgSize = 24; + const midy = options.y + options.h / 2; + + let text = info.text; + const maxTextWidth = options.w - imgSize - padding * 3; + + if (g.stringWidth(text) > maxTextWidth) { + while (g.stringWidth(text + "...") > maxTextWidth && text.length > 0) { + text = text.substr(0, text.length - 1); + } + text = text + "..."; + } + + const textWidth = g.stringWidth(text); + const totalContentWidth = imgSize + padding + textWidth; + const startX = options.x + (options.w - totalContentWidth) / 2; + + if (info.img) { + g.drawImage(info.img, startX, midy - 12); + } + + g.setColor(g.theme.fg); + g.setFontAlign(-1, 0); + g.drawString(text, startX + imgSize + padding, midy); + }; + + clockInfoMenu = require("clock_info").addInteractive(clockInfoItems, { + app: "tileclk", + x: 0, + y: positions.seconds.y + widgetYOffset - 10, + w: width, + h: 50, + draw: clockInfoDraw + }); + } catch(e) { + clockInfoMenu = null; + } + } + + // ===== MAIN CLOCK DRAW ===== + function drawClock() { + g.clear(Bangle.appRect); + if (settings.widgets !== "hide") Bangle.drawWidgets(); + lastTime = ""; + lastSeconds = ""; + isColonDrawn = false; + + // Load saved state + loadDisplayState(); + + // Setup clock info and touch handler + setupClockInfo(); + setupTouchHandler(); + + // Determine initial display based on saved preferences and current settings + if (showingClockInfo && clockInfoMenu) { + // User was viewing clock info - restore it but unfocused + clockInfoUnfocused = true; + clockInfoMenu.focus = false; + clockInfoMenu.redraw(); + } else if (userPreferClockInfo && clockInfoMenu) { + // User prefers clock info - show it unfocused + showingClockInfo = true; + clockInfoUnfocused = true; + clockInfoMenu.focus = false; + clockInfoMenu.redraw(); + } else if (showSeconds && !userDismissedClockInfo) { + // Seconds are enabled and user hasn't dismissed clock info + showingClockInfo = false; + clockInfoUnfocused = false; + } else if (!showSeconds && !userDismissedClockInfo && settings.seconds === "dynamic" && clockInfoMenu) { + // Dynamic mode when locked - show clock info unfocused if not dismissed + showingClockInfo = true; + clockInfoUnfocused = true; + clockInfoMenu.focus = false; + clockInfoMenu.redraw(); + } else { + // Default: show nothing in seconds area + showingClockInfo = false; + clockInfoUnfocused = false; + } + + updateAndAnimTime(); + } + + // ===== UI SETUP ===== + Bangle.setUI({ + mode: "clock", + remove: function() { + isDrawing = false; + saveDisplayState(); + + if (drawTimeout) clearTimeout(drawTimeout); + if (secondsTimeout) clearTimeout(secondsTimeout); + + if (clockInfoMenu) { + clockInfoMenu.remove(); + clockInfoMenu = null; + } + } + }); + + // ===== WIDGET SETUP ===== + Bangle.loadWidgets(); + if (settings.widgets === "hide") require("widget_utils").hide(); + else if (settings.widgets === "swipe") require("widget_utils").swipeOn(); + + // ===== LOCK HANDLER ===== + Bangle.on('lock', function(isLocked) { + if (settings.seconds === "dynamic") { + showSeconds = !isLocked; + if (isLocked) { + if (!showingClockInfo && lastSeconds !== "") { + if (secondsTimeout) clearTimeout(secondsTimeout); + clearSeconds(() => { + if (!userDismissedClockInfo) { + showingClockInfo = true; + clockInfoUnfocused = true; + if (clockInfoMenu) { + clockInfoMenu.focus = false; + clockInfoMenu.redraw(); + } + } + g.flip(); + }); + } + } else { + if (!userPreferClockInfo && showingClockInfo && !userDismissedClockInfo) { + switchToSeconds(); + } else if (showSeconds && !showingClockInfo && !userPreferClockInfo) { + showingClockInfo = false; + updateAndAnimTime(); + } + } + } + }); + + // ===== START CLOCK ===== + drawClock(); +})(); \ No newline at end of file diff --git a/apps/tileclk/metadata.json b/apps/tileclk/metadata.json new file mode 100644 index 000000000..e987fee29 --- /dev/null +++ b/apps/tileclk/metadata.json @@ -0,0 +1,28 @@ +{ + "id": "tileclk", + "name": "Tile Clock", + "shortName": "Tile Clock", + "version": "0.01", + "description": "A tile-based digital clock with animated transitions, customizable borders, and clock info integration.", + "icon": "screenshot.png", + "dependencies" : { "clock_info":"module" }, + "screenshots": [ + {"url": "screenshot.png"}, + {"url": "screenshot2.png"}, + {"url": "screenshot3.png"}, + {"url": "screenshot4.png"} + ], + "type": "clock", + "tags": "clock,digital,animated,tiles,clkinfo", + "supports": ["BANGLEJS2"], + "readme": "README.md", + "data": [ + {"name": "tileclk.settings.json"}, + {"name": "tileclk.state.json"} + ], + "storage": [ + {"name": "tileclk.app.js", "url": "app.js"}, + {"name": "tileclk.settings.js", "url": "settings.js"}, + {"name": "tileclk.img", "url": "app-icon.js", "evaluate": true} + ] +} \ No newline at end of file diff --git a/apps/tileclk/screenshot.png b/apps/tileclk/screenshot.png new file mode 100644 index 000000000..f080cec30 Binary files /dev/null and b/apps/tileclk/screenshot.png differ diff --git a/apps/tileclk/screenshot2.png b/apps/tileclk/screenshot2.png new file mode 100644 index 000000000..d2e30dcca Binary files /dev/null and b/apps/tileclk/screenshot2.png differ diff --git a/apps/tileclk/screenshot3.png b/apps/tileclk/screenshot3.png new file mode 100644 index 000000000..5389f7572 Binary files /dev/null and b/apps/tileclk/screenshot3.png differ diff --git a/apps/tileclk/screenshot4.png b/apps/tileclk/screenshot4.png new file mode 100644 index 000000000..8c7f2a327 Binary files /dev/null and b/apps/tileclk/screenshot4.png differ diff --git a/apps/tileclk/settings.js b/apps/tileclk/settings.js new file mode 100644 index 000000000..446196e8b --- /dev/null +++ b/apps/tileclk/settings.js @@ -0,0 +1,80 @@ +(function(back){ + let appSettings = Object.assign({ + widgets: "show", + seconds: "hide", + borders: true, + borderColor: "theme", + haptics: true + }, require("Storage").readJSON("tileclk.json", true) || {}); + + function writeSettings() { + require("Storage").writeJSON("tileclk.json", appSettings); + } + + const colorOptions = { + "Theme (bgH)": "theme", + "Black": "#000000", + "White": "#FFFFFF", + "Dark Gray": "#404040", + "Light Gray": "#808080", + "Red": "#FF0000", + "Green": "#00FF00", + "Blue": "#0000FF", + "Cyan": "#00FFFF", + "Magenta": "#FF00FF", + "Yellow": "#FFFF00" + }; + + function showMenu() { + E.showMenu({ + "": { "title": "Tile Clock" }, + "< Back": () => back(), + "Widgets:": { + value: appSettings.widgets === "show" ? 0 : appSettings.widgets === "hide" ? 1 : 2, + min: 0, + max: 2, + onchange: v => { + appSettings.widgets = ["show", "hide", "swipe"][v]; + writeSettings(); + }, + format: v => ["Show", "Hide", "Swipe"][v] + }, + "Seconds:": { + value: appSettings.seconds === "show" ? 0 : appSettings.seconds === "hide" ? 1 : 2, + min: 0, + max: 2, + onchange: v => { + appSettings.seconds = ["show", "hide", "dynamic"][v]; + writeSettings(); + }, + format: v => ["Show", "Hide", "Dynamic"][v] + }, + "Tile Borders:": { + value: appSettings.borders === false ? false : true, + onchange: v => { + appSettings.borders = v; + writeSettings(); + } + }, + "Border Color": { + value: Object.values(colorOptions).indexOf(appSettings.borderColor || "theme"), + min: 0, + max: Object.keys(colorOptions).length - 1, + onchange: v => { + appSettings.borderColor = Object.values(colorOptions)[v]; + writeSettings(); + }, + format: v => Object.keys(colorOptions)[v] + }, + "Haptic Feedback:": { + value: appSettings.haptics !== false, + onchange: v => { + appSettings.haptics = v; + writeSettings(); + } + } + }); + } + + showMenu(); +}) \ No newline at end of file