diff --git a/apps/chronlog/ChangeLog b/apps/chronlog/ChangeLog new file mode 100644 index 000000000..5560f00bc --- /dev/null +++ b/apps/chronlog/ChangeLog @@ -0,0 +1 @@ +0.01: New App! diff --git a/apps/chronlog/README.md b/apps/chronlog/README.md new file mode 100644 index 000000000..7c542cf73 --- /dev/null +++ b/apps/chronlog/README.md @@ -0,0 +1,53 @@ +# Chrono Logger + +Record times active on a task, course, work or anything really. + +**Disclaimer:** No one is responsible for any loss of data you recorded with this app. If you run into problems please report as advised under **Requests** below. + +With time on your side and a little help from your friends - you'll surely triumph over Lavos in the end! + +![dump](dump.png) ![dump1](dump1.png) ![dump2](dump2.png) ![dump3](dump3.png) ![dump4](dump4.png) ![dump5](dump5.png) ![dump6](dump6.png) + + +## Usage + +Click the large green button to log the start of your activity. Click the now red button again to log that you stopped. + +## Features + +- Saves to file on every toggling of the active state. + - csv file contents looks like: + ``` + 1,Start,2024-03-02T15:18:09 GMT+0200 + 2,Note,Critical hit! + 3,Stop,2024-03-02T15:19:17 GMT+0200 + ``` +- Add annotations to the log. +- Create and switch between multiple logs. +- Sync log files to an Android device through Gadgetbridge (Needs pending code changes to Gadgetbridge). +- App state is restored when you start the app again. + +## Controls + +- Large button to toggle active state. +- Menu icon to access additional functionality. +- Hardware button exits menus, closes the app on the main screen. + +## TODO and notes + +- Delete individual tasks/logs through the app? +- Reset everything through the app? +- Scan for chronlog storage files that somehow no longer have tasks associated with it? +- Complete the Gadgetbridge side of things for sync. +- Sync to iOS? +- Inspect log files through the app, similarly to Recorder app? +- Changes to Android file system permissions makes it not always trivial to access the synced files. + + +## Requests + +Tag @thyttan in an issue to https://gitbub.com/espruino/BangleApps/issues to report problems or suggestions. + +## Creator + +[thyttan](https://github.com/thyttan) diff --git a/apps/chronlog/app-icon.js b/apps/chronlog/app-icon.js new file mode 100644 index 000000000..dc25e4b5b --- /dev/null +++ b/apps/chronlog/app-icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEw4UA///gElq3X0ELJf4AiitAAYMBqgKEgNVrgEBmtVCAQABgtVr/Agf1qtQEQlpq6QB6tpEgkVywLDywLEq2uyoLB6wEBBZAECBYda32lBYIECBZ9W3wjDAgILPquWqoACAgILEtILDAgKOEAAyQCRwIAGSAUVBY6ECBZYGD7WnAoYLF9WrBYupAoWq1QECtQLBtWdBYt21QLC1LfBBYVfA4ILBlWq1f9rWVv/q1WoBYMKCgOvTYP6AoOgBYMCAoIAFwCQCBY6nDGAIAEFwQkIEQZVCBQZRCAAcGBYeQBYoYDCwwYECw5KC0gKIAH4APA=")) diff --git a/apps/chronlog/app.js b/apps/chronlog/app.js new file mode 100644 index 000000000..827ca14e1 --- /dev/null +++ b/apps/chronlog/app.js @@ -0,0 +1,376 @@ +// TODO: +// - Add more /*LANG*/ tags for translations. +// - Check if there are chronlog storage files that should be added to tasks. + +{ + const storage = require("Storage"); + let appData = storage.readJSON("chronlog.json", true) || { + currentTask : "default", + tasks : { + default: { + file : "chronlog_default.csv", // Existing default task log file + state : "stopped", + lineNumber : 0, + lastLine : "", + lastSyncedLine : "", + }, + // Add more tasks as needed + }, + }; + let currentTask = appData.currentTask; + let tasks = appData.tasks; + delete appData; + + let themeColors = g.theme; + + let logEntry; // Avoid previous lint warning + + // Function to draw the Start/Stop button with play and pause icons + let drawButton = ()=>{ + var btnWidth = g.getWidth() - 40; + var btnHeight = 50; + var btnX = 20; + var btnY = (g.getHeight() - btnHeight) / 2; + var cornerRadius = 25; + + var isStopped = tasks[currentTask].state === "stopped"; + g.setColor(isStopped ? "#0F0" : "#F00"); // Set color to green when stopped and red when started + + // Draw rounded corners of the button + g.fillCircle(btnX + cornerRadius, btnY + cornerRadius, cornerRadius); + g.fillCircle(btnX + btnWidth - cornerRadius, btnY + cornerRadius, cornerRadius); + g.fillCircle(btnX + cornerRadius, btnY + btnHeight - cornerRadius, cornerRadius); + g.fillCircle(btnX + btnWidth - cornerRadius, btnY + btnHeight - cornerRadius, cornerRadius); + + // Draw rectangles to fill in the button + g.fillRect(btnX + cornerRadius, btnY, btnX + btnWidth - cornerRadius, btnY + btnHeight); + g.fillRect(btnX, btnY + cornerRadius, btnX + btnWidth, btnY + btnHeight - cornerRadius); + + g.setColor(themeColors.bg); // Set icon color to contrast against the button's color + + // Center the icon within the button + var iconX = btnX + btnWidth / 2; + var iconY = btnY + btnHeight / 2; + + if (isStopped) { + // Draw play icon + var playSize = 10; // Side length of the play triangle + var offset = playSize / Math.sqrt(3) - 3; + g.fillPoly([ + iconX - playSize, iconY - playSize + offset, + iconX - playSize, iconY + playSize + offset, + iconX + playSize * 2 / Math.sqrt(3), iconY + offset + ]); + } else { + // Draw pause icon + var barWidth = 5; // Width of pause bars + var barHeight = btnHeight / 2; // Height of pause bars + var barSpacing = 5; // Spacing between pause bars + g.fillRect(iconX - barSpacing / 2 - barWidth, iconY - barHeight / 2, iconX - barSpacing / 2, iconY + barHeight / 2); + g.fillRect(iconX + barSpacing / 2, iconY - barHeight / 2, iconX + barSpacing / 2 + barWidth, iconY + barHeight / 2); + } + }; + + let drawHamburgerMenu = ()=>{ + var x = g.getWidth() / 2; // Center the hamburger menu horizontally + var y = (7/8)*g.getHeight(); // Position it near the bottom + var lineLength = 18; // Length of the hamburger lines + var spacing = 6; // Space between the lines + + g.setColor(themeColors.fg); // Set color to foreground color for the icon + // Draw three horizontal lines + for (var i = -1; i <= 1; i++) { + g.fillRect(x - lineLength/2, y + i * spacing - 1, x + lineLength/2, y + i * spacing + 1); + } + }; + + // Function to draw the task name centered between the widget field and the start/stop button + let drawTaskName = ()=>{ + g.setFont("Vector", 20); // Set a smaller font for the task name display + + // Calculate position to center the task name horizontally + var x = (g.getWidth()) / 2; + + // Calculate position to center the task name vertically between the widget field and the start/stop button + var y = g.getHeight()/4; // Center vertically + + g.setColor(themeColors.fg).setFontAlign(0,0); // Set text color to foreground color + g.drawString(currentTask, x, y); // Draw the task name centered on the screen + }; + + // Function to draw the last log entry of the current task + let drawLastLogEntry = ()=>{ + g.setFont("Vector", 10); // Set a smaller font for the task name display + + // Calculate position to center the log entry horizontally + var x = (g.getWidth()) / 2; + + // Calculate position to place the log entry properly between the start/stop button and hamburger menu + var btnBottomY = (g.getHeight() + 50) / 2; // Y-coordinate of the bottom of the start/stop button + var menuBtnYTop = g.getHeight() * (5 / 6); // Y-coordinate of the top of the hamburger menu button + var y = btnBottomY + (menuBtnYTop - btnBottomY) / 2 + 2; // Center vertically between button and menu + + g.setColor(themeColors.fg).setFontAlign(0,0); // Set text color to foreground color + g.drawString(g.wrapString(tasks[currentTask].lastLine, 150).join("\n"), x, y); + }; + + /* + // Helper function to read the last log entry from the current task's log file + let updateLastLogEntry = ()=>{ + var filename = tasks[currentTask].file; + var file = require("Storage").open(filename, "r"); + var lastLine = ""; + var line; + while ((line = file.readLine()) !== undefined) { + lastLine = line; // Keep reading until the last line + } + tasks[currentTask].lastLine = lastLine; + }; + */ + + // Main UI drawing function + let drawMainMenu = ()=>{ + g.clear(); + Bangle.drawWidgets(); // Draw any active widgets + g.setColor(themeColors.bg); // Set color to theme's background color + g.fillRect(Bangle.appRect); // Fill the app area with the background color + + drawTaskName(); // Draw the centered task name + drawLastLogEntry(); // Draw the last log entry of the current task + drawButton(); // Draw the Start/Stop toggle button + drawHamburgerMenu(); // Draw the hamburger menu button icon + + //g.flip(); // Send graphics to the display + }; + + // Function to toggle the active state + let toggleChronlog = ()=>{ + var dateObj = new Date(); + var dateObjStrSplit = dateObj.toString().split(" "); + var currentTime = dateObj.getFullYear().toString() + "-" + (dateObj.getMonth()<10?"0":"") + dateObj.getMonth().toString() + "-" + (dateObj.getDate()<10?"0":"") + dateObj.getDate().toString() + "T" + (dateObj.getHours()<10?"0":"") + dateObj.getHours().toString() + ":" + (dateObj.getMinutes()<10?"0":"") + dateObj.getMinutes().toString() + ":" + (dateObj.getSeconds()<10?"0":"") + dateObj.getSeconds().toString() + " " + dateObjStrSplit[dateObjStrSplit.length-1]; + + tasks[currentTask].lineNumber = Number(tasks[currentTask].lineNumber) + 1; + logEntry = tasks[currentTask].lineNumber + (tasks[currentTask].state === "stopped" ? ",Start," : ",Stop,") + currentTime + "\n"; + var filename = tasks[currentTask].file; + + // Open the appropriate file and append the log entry + var file = require("Storage").open(filename, "a"); + file.write(logEntry); + tasks[currentTask].lastLine = logEntry; + + // Toggle the state and update the button text + tasks[currentTask].state = tasks[currentTask].state === "stopped" ? "started" : "stopped"; + drawMainMenu(); // Redraw the main UI + }; + + // Define the touch handler function for the main menu + let handleMainMenuTouch = (button, xy)=>{ + var btnTopY = (g.getHeight() - 50) / 2; + var btnBottomY = btnTopY + 50; + var menuBtnYTop = (7/8)*g.getHeight() - 15; + var menuBtnYBottom = (7/8)*g.getHeight() + 15; + var menuBtnXLeft = (g.getWidth() / 2) - 15; + var menuBtnXRight = (g.getWidth() / 2) + 15; + + // Detect if the touch is within the toggle button area + if (xy.x >= 20 && xy.x <= (g.getWidth() - 20) && xy.y > btnTopY && xy.y < btnBottomY) { + toggleChronlog(); + } + // Detect if the touch is within the hamburger menu button area + else if (xy.x >= menuBtnXLeft && xy.x <= menuBtnXRight && xy.y >= menuBtnYTop && xy.y <= menuBtnYBottom) { + showMenu(); + } + }; + + // Function to attach the touch event listener + let setMainUI = ()=>{ + Bangle.setUI({ + mode: "custom", + back: load, + touch: handleMainMenuTouch + }); + }; + + let saveAppState = ()=>{ + let appData = { + currentTask : currentTask, + tasks : tasks, + }; + require("Storage").writeJSON("chronlog.json", appData); + }; + // Set up a listener for the 'kill' event + E.on('kill', saveAppState); + + // Function to switch to a selected task + let switchTask = (taskName)=>{ + currentTask = taskName; // Update the current task + + // Reinitialize the UI elements + setMainUI(); + drawMainMenu(); // Redraw UI to reflect the task change and the button state + }; + + // Function to create a new task + let createNewTask = ()=>{ + // Prompt the user to input the task's name + require("textinput").input({ + text: "" // Default empty text for new task + }).then(result => { + var taskName = result; // Store the result from text input + if (taskName) { + if (tasks.hasOwnProperty(taskName)) { + // Task already exists, handle this case as needed + E.showAlert(/*LANG*/"Task already exists", "Error").then(drawMainMenu); + } else { + // Create a new task log file for the new task + var filename = "chronlog_" + taskName.replace(/\W+/g, "_") + ".csv"; + tasks[taskName] = { + file : filename, + state : "stopped", + lineNumber : 0, + lastLine : "", + lastSyncedLine : "", + }; + + currentTask = taskName; + + setMainUI(); + drawMainMenu(); // Redraw UI with the new task + } + } else { + setMainUI(); + drawMainMenu(); // User cancelled, redraw main menu + } + }).catch(e => { + console.log("Text input error", e); + setMainUI(); + drawMainMenu(); // In case of error also redraw main menu + }); + }; + + // Function to display the list of tasks for selection + let chooseTask = ()=>{ + // Construct the tasks menu from the tasks object + var taskMenu = { + "": { "title": /*LANG*/"Choose Task", + "back" : function() { + setMainUI(); // Reattach when the menu is closed + drawMainMenu(); // Cancel task selection + } + } + }; + for (var taskName in tasks) { + if (!tasks.hasOwnProperty(taskName)) continue; + taskMenu[taskName] = (function(name) { + return function() { + switchTask(name); + }; + })(taskName); + } + + // Add a menu option for creating a new task + taskMenu[/*LANG*/"Create New Task"] = createNewTask; + + E.showMenu(taskMenu); // Display the task selection + }; + + // Function to annotate the current or last work session + let annotateTask = ()=>{ + + // Prompt the user to input the annotation text + require("textinput").input({ + text: "" // Default empty text for annotation + }).then(result => { + var annotationText = result.trim(); + if (annotationText) { + // Append annotation to the last or current log entry + tasks[currentTask].lineNumber ++; + var annotatedEntry = tasks[currentTask].lineNumber + /*LANG*/",Note," + annotationText + "\n"; + var filename = tasks[currentTask].file; + var file = require("Storage").open(filename, "a"); + file.write(annotatedEntry); + tasks[currentTask].lastLine = annotatedEntry; + setMainUI(); + drawMainMenu(); // Redraw UI after adding the annotation + } else { + // User cancelled, so we do nothing and just redraw the main menu + setMainUI(); + drawMainMenu(); + } + }).catch(e => { + console.log("Annotation input error", e); + setMainUI(); + drawMainMenu(); // In case of error also redraw main menu + }); + }; + + let syncToAndroid = (taskName, isFullSync)=>{ + let mode = "a"; + if (isFullSync) mode = "w"; + let lastSyncedLine = tasks[taskName].lastSyncedLine || 0; + let taskNameValidFileName = taskName.replace(" ","_"); // FIXME: Should use something similar to replaceAll using a regular expression to catch all illegal characters. + + let storageFile = require("Storage").open("chronlog_"+taskNameValidFileName+".csv", "r"); + let contents = storageFile.readLine(); + let lineNumber = contents ? contents.slice(0, contents.indexOf(",")) : 0; + let shouldSyncLine = ()=>{return (contents && (isFullSync || (Number(lineNumber)>Number(lastSyncedLine))));}; + let doSyncLine = (mde)=>{Bluetooth.println(JSON.stringify({t:"file", n:"chronlog_"+taskNameValidFileName+".csv", c:contents, m:mde}));}; + + if (shouldSyncLine()) doSyncLine(mode); + contents = storageFile.readLine(); + while (contents) { + lineNumber = contents.slice(0, contents.indexOf(",")); // Could theoretically do with `lineNumber++`, but this is more robust in case numbering in file ended up irregular. + if (shouldSyncLine()) doSyncLine("a"); + contents = storageFile.readLine(); + } + tasks[taskName].lastSyncedLine = lineNumber; + }; + + // Function to display the list of tasks for selection + let syncTasks = ()=>{ + let isToDoFullSync = false; + // Construct the tasks menu from the tasks object + var syncMenu = { + "": { "title": /*LANG*/"Sync Tasks", + "back" : function() { + setMainUI(); // Reattach when the menu is closed + drawMainMenu(); // Cancel task selection + } + } + }; + syncMenu[/*LANG*/"Full Resyncs"] = { + value: !!isToDoFullSync, // !! converts undefined to false + onchange: ()=>{ + isToDoFullSync = !isToDoFullSync + }, + } + for (var taskName in tasks) { + if (!tasks.hasOwnProperty(taskName)) continue; + syncMenu[taskName] = (function(name) { + return function() {syncToAndroid(name,isToDoFullSync);}; + })(taskName); + } + + E.showMenu(syncMenu); // Display the task selection + }; + + let showMenu = ()=>{ + var menu = { + "": { "title": /*LANG*/"Menu", + "back": function() { + setMainUI(); // Reattach when the menu is closed + drawMainMenu(); // Redraw the main UI when closing the menu + }, + }, + /*LANG*/"Annotate": annotateTask, // Now calls the real annotation function + /*LANG*/"Change Task": chooseTask, // Opens the task selection screen + /*LANG*/"Sync to Android": syncTasks, + }; + E.showMenu(menu); + }; + + Bangle.loadWidgets(); + drawMainMenu(); // Draw the main UI when the app starts + // When the application starts, attach the touch event listener + setMainUI(); +} diff --git a/apps/chronlog/app.png b/apps/chronlog/app.png new file mode 100644 index 000000000..c21a147ea Binary files /dev/null and b/apps/chronlog/app.png differ diff --git a/apps/chronlog/dump.png b/apps/chronlog/dump.png new file mode 100644 index 000000000..0c40b190e Binary files /dev/null and b/apps/chronlog/dump.png differ diff --git a/apps/chronlog/dump1.png b/apps/chronlog/dump1.png new file mode 100644 index 000000000..04f625f04 Binary files /dev/null and b/apps/chronlog/dump1.png differ diff --git a/apps/chronlog/dump2.png b/apps/chronlog/dump2.png new file mode 100644 index 000000000..be0791659 Binary files /dev/null and b/apps/chronlog/dump2.png differ diff --git a/apps/chronlog/dump3.png b/apps/chronlog/dump3.png new file mode 100644 index 000000000..eeeba525f Binary files /dev/null and b/apps/chronlog/dump3.png differ diff --git a/apps/chronlog/dump4.png b/apps/chronlog/dump4.png new file mode 100644 index 000000000..b1bd51669 Binary files /dev/null and b/apps/chronlog/dump4.png differ diff --git a/apps/chronlog/dump5.png b/apps/chronlog/dump5.png new file mode 100644 index 000000000..debf919cc Binary files /dev/null and b/apps/chronlog/dump5.png differ diff --git a/apps/chronlog/dump6.png b/apps/chronlog/dump6.png new file mode 100644 index 000000000..29a06b68f Binary files /dev/null and b/apps/chronlog/dump6.png differ diff --git a/apps/chronlog/metadata.json b/apps/chronlog/metadata.json new file mode 100644 index 000000000..8ed618b27 --- /dev/null +++ b/apps/chronlog/metadata.json @@ -0,0 +1,14 @@ +{ "id": "chronlog", + "name": "Chrono Logger", + "version":"0.01", + "description": "Record time active on a task, course, work or anything really.", + "icon": "app.png", + "tags": "logging, record, work, tasks", + "supports" : ["BANGLEJS2"], + "readme": "README.md", + "screenshots" : [ { "url":"dump.png"}, { "url":"dump1.png" }, { "url":"dump2.png" }, { "url":"dump3.png" }, { "url":"dump4.png" }, { "url":"dump5.png" }, { "url":"dump6.png" } ], + "storage": [ + {"name":"chronlog.app.js","url":"app.js"}, + {"name":"chronlog.img","url":"app-icon.js","evaluate":true} + ] +}