diff --git a/README.md b/README.md index ff2431f..c3ca8ee 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Station Timer +[![GitHub release](https://img.shields.io/badge/download-latest-blue.svg)](https://github.com/aouerfelli/station-timer/releases/latest) A minimal timer application built with [Electron](http://electron.atom.io) that counts down and loops for a given number of stations. This was originally @@ -7,7 +8,9 @@ stations set up and would allocate a certain amount of time to stay at each station, and would then give some additional time to clean up and go to the next station. -## Releases (NOT AVAILABE YET) +![](screenshots.gif) + +## Releases There are builds available for Windows and macOS, since those are the only platforms I am able to use and test on at the moment. If you would like to build for your own platform, you can follow the steps [below](#Build). diff --git a/app/res/audio/beep.wav b/app/assets/audio/beep.wav similarity index 100% rename from app/res/audio/beep.wav rename to app/assets/audio/beep.wav diff --git a/app/res/fonts/Roboto-Bold.woff2 b/app/assets/fonts/Roboto-Bold.woff2 similarity index 100% rename from app/res/fonts/Roboto-Bold.woff2 rename to app/assets/fonts/Roboto-Bold.woff2 diff --git a/app/res/fonts/Roboto-Regular.woff2 b/app/assets/fonts/Roboto-Regular.woff2 similarity index 100% rename from app/res/fonts/Roboto-Regular.woff2 rename to app/assets/fonts/Roboto-Regular.woff2 diff --git a/app/main.js b/app/main.js index cec030d..aa9ddd0 100644 --- a/app/main.js +++ b/app/main.js @@ -2,7 +2,7 @@ const {app, BrowserWindow, ipcMain} = require('electron'); let mainWindow, setupWindow, pauseWindow; -function createMainWindow() { +function createMainWindow () { mainWindow = new BrowserWindow({ fullscreen: true, frame: false @@ -15,7 +15,7 @@ function createMainWindow() { mainWindow.on('closed', () => mainWindow = null); } -function createSetupModalWindow() { +function createSetupModalWindow () { setupWindow = new BrowserWindow({ parent: mainWindow, modal: true, @@ -33,7 +33,7 @@ function createSetupModalWindow() { setupWindow.on('closed', () => setupWindow = null); } -function createPauseModalWindow() { +function createPauseModalWindow () { mainWindow.webContents.executeJavaScript( 'document.body.classList.add(\'dim\')' ); @@ -62,16 +62,15 @@ function createPauseModalWindow() { }); } -function createStartWindows() { +function createStartWindows () { createMainWindow(); createSetupModalWindow(); } -function exitApp() { +function exitApp () { // Do not exit the program on macOS (standard OS-specific behaviour). - // Instead, close all open windows. + // Instead, lose app focus and close all open windows. if (process.platform === 'darwin') { - // Lose app and window focus before closing windows app.hide(); BrowserWindow.getAllWindows().forEach(win => win.close()); } else { diff --git a/app/renderers/index.js b/app/renderers/index.js index b90c653..e6d2532 100644 --- a/app/renderers/index.js +++ b/app/renderers/index.js @@ -1,33 +1,28 @@ const {ipcRenderer, remote} = require('electron'); +const webContents = remote.getCurrentWebContents(); const counterTextView = document.getElementById('counter'); const secondProgressBar = document.getElementById('progress'); const infoTextView = document.getElementById('info'); const pauseButton = document.getElementById('pause'); const restartButton = document.getElementById('restart'); +const muteOnButton = document.getElementById('mute-on'); +const muteOffButton = document.getElementById('mute-off'); const exitButton = document.getElementById('exit'); -const beepAudio = new Audio('../res/audio/beep.wav'); +const beepAudio = new Audio('../assets/audio/beep.wav'); // Object containing strings used in the counter -// I know it's pretty ugly, but I'm sure you've seen worse -const text = (() => { - try { - return require('./../res/strings.json'); - } catch (err) { - // If the JSON wasn't found or couldn't be parsed, then set default values - return { - counterText: { - end: '0' - }, - infoText: { - active: 'Complete your activity', - coolDown: 'Go to your next station', - complete: 'Return to your original station' - } - }; +const text = { + counterText: { + end: '0' + }, + infoText: { + active: 'Complete your activity', + coolDown: 'Go to your next station', + complete: 'Return to your original station' } -})(); +}; // Object containing values for duration, break duration and number of repeats let settings; @@ -41,9 +36,21 @@ pauseButton.addEventListener('click', () => { }); restartButton.addEventListener('click', () => - remote.getCurrentWebContents().send('start-timer', settings) + webContents.send('start-timer', settings) ); +muteOnButton.addEventListener('click', () => { + webContents.setAudioMuted(true); + muteOnButton.parentElement.style.display = 'none'; + muteOffButton.parentElement.style.display = ''; +}); + +muteOffButton.addEventListener('click', () => { + webContents.setAudioMuted(false); + muteOffButton.parentElement.style.display = 'none'; + muteOnButton.parentElement.style.display = ''; +}); + exitButton.addEventListener('click', () => ipcRenderer.send('exit') ); @@ -64,7 +71,7 @@ counterTextView.addEventListener('click', () => { * * @param generatorFn the generator function that will yield Promises. */ -function async(generatorFn) { +function async (generatorFn) { function continuer(verb, arg) { let result; try { @@ -83,7 +90,7 @@ function async(generatorFn) { return onResolved(); } -function skipTransition(elements, action) { +function skipTransition (elements, action) { // If a single element is given, place it in an array if (elements.constructor !== Array) { elements = [elements]; @@ -99,13 +106,13 @@ function skipTransition(elements, action) { }); } -function setProgressBar() { +function setProgressBar () { skipTransition(secondProgressBar, () => secondProgressBar.classList.remove('expand')); secondProgressBar.classList.add('expand'); } -function getFormattedTime(seconds) { +function getFormattedTime (seconds) { // Get units of time (from seconds up to hours) let hh = parseInt(seconds / 3600, 10); let mm = parseInt((seconds % 3600) / 60, 10); @@ -121,19 +128,19 @@ function getFormattedTime(seconds) { return hh + mm + ss; } -function sleep(ms) { +function sleep (ms) { return new Promise(resolve => setTimeout(resolve, ms)); } -function waitPause() { +function pauseWait () { // The pause-wait channel will return a value of false when the pause modal is // closed, which we can set to the paused flag. When the flag is set, the // Promise will be resolved. return Promise.resolve(paused = ipcRenderer.sendSync('pause-wait')); } -function countdown(duration, view, onEachSecond) { - function action() { +function countdown (duration, view, onEachSecond) { + function action () { // Run the onEachSecond function if it is given if (typeof onEachSecond === 'function') { onEachSecond(); @@ -145,15 +152,15 @@ function countdown(duration, view, onEachSecond) { // We'll be decrementing duration each second in action() while (duration > 0) { if (paused) { - yield waitPause(); + yield pauseWait(); } else { action(); yield sleep(1000); } } - // Check before ending countdown + // Check for pause before ending countdown if (paused) { - yield waitPause(); + yield pauseWait(); } })); } @@ -161,7 +168,7 @@ function countdown(duration, view, onEachSecond) { ipcRenderer.on('start-timer', (evt, userSettings) => { let {duration, breakDuration, numRepeats} = settings = userSettings; - function resetTimer() { + function resetTimer () { // Reset elements to their intended initial visibility restartButton.parentElement.style.display = 'none'; pauseButton.parentElement.style.display = ''; @@ -172,7 +179,7 @@ ipcRenderer.on('start-timer', (evt, userSettings) => { infoTextView.classList = ''; } - function durationCountdown() { + function durationCountdown () { counterTextView.classList.remove('red'); counterTextView.classList.add('primary'); secondProgressBar.classList.remove('red'); @@ -180,7 +187,7 @@ ipcRenderer.on('start-timer', (evt, userSettings) => { return countdown(duration, counterTextView, setProgressBar); } - function breakDurationCountdown() { + function breakDurationCountdown () { counterTextView.classList.remove('primary'); counterTextView.classList.add('red'); secondProgressBar.classList.add('red'); @@ -191,7 +198,7 @@ ipcRenderer.on('start-timer', (evt, userSettings) => { }); } - function endTimer() { + function endTimer () { // Setting end classes secondProgressBar.classList.add('remove'); skipTransition(counterTextView, () => diff --git a/app/renderers/setup.js b/app/renderers/setup.js index 878f8da..bc0abff 100644 --- a/app/renderers/setup.js +++ b/app/renderers/setup.js @@ -1,20 +1,25 @@ const {ipcRenderer, remote} = require('electron'); const setupForm = document.forms.namedItem('setup'); +const setupFormInputs = Array.from( + setupForm.querySelectorAll('input[type="number"]') +); const exitButton = document.getElementById('exit'); -Array.from(document.querySelectorAll('input[type="number"]')).forEach(input => { - input.addEventListener('input', () => { - // The handling of empty inputs is done in CSS. If the input is not - // empty and the value is an empty string, that means the input is - // invalid. - let valid = input.value !== '' && - (Number.isInteger(Number(input.value)) - && (input.min === '' || - parseInt(input.value, 10) >= parseInt(input.min, 10)) - && (input.max === '' || - parseInt(input.value, 10) <= parseInt(input.max, 10))); +function checkValidInput (value, min, max) { + // The handling of empty inputs is done in CSS. If the input is not empty and + // the value is an empty string, that means the input is invalid. + return value !== '' && + (Number.isSafeInteger(Number(value)) + && (min === '' || + parseInt(value, 10) >= parseInt(min, 10)) + && (max === '' || + parseInt(value, 10) <= parseInt(max, 10))); +} +setupFormInputs.forEach(input => { + input.addEventListener('input', () => { + let valid = checkValidInput(input.value, input.min, input.max); input.classList.toggle('error', !valid); }); }); @@ -22,6 +27,19 @@ Array.from(document.querySelectorAll('input[type="number"]')).forEach(input => { setupForm.addEventListener('submit', evt => { evt.preventDefault(); + let valid = setupFormInputs.every(input => { + let invalid = input.classList.contains('error'); + if (invalid) { + // If any of the form inputs contain errors, focus on it + input.focus(); + } + return !invalid; + }); + // Return early (don't finish submitting the form) if not all inputs are valid + if (!valid) { + return; + } + ipcRenderer.send( 'setup-timer', Object.assign(...Array.from(new FormData(setupForm)) diff --git a/app/res/strings.json b/app/res/strings.json deleted file mode 100644 index 0e0a009..0000000 --- a/app/res/strings.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "counterText": { - "end": "0" - }, - "infoText": { - "active": "Complete your activity", - "coolDown": "Go to your next station", - "complete": "Return to your original station" - } -} \ No newline at end of file diff --git a/app/styles/_base.css b/app/styles/base.css similarity index 93% rename from app/styles/_base.css rename to app/styles/base.css index f9025fd..704694e 100644 --- a/app/styles/_base.css +++ b/app/styles/base.css @@ -1,9 +1,9 @@ /* ================================================ * Accumulated styles common to all HTML documents * ================================================ */ - /* Note that this stylesheet will be loaded using HTML instead of being - * imported directly into the other stylesheets for performance reasons - * (importing does not load styles asynchronously). */ +/* Note that this stylesheet will be loaded using HTML instead of being + * imported directly into the other stylesheets for performance reasons + * (importing does not load styles asynchronously). */ /* ===================== * Local Fonts [*.html] @@ -15,7 +15,7 @@ font-style: normal; font-weight: 400; src: local('Roboto'), local('Roboto-Regular'), - url('../res/fonts/Roboto-Regular.woff2') format('woff2'); + url('../assets/fonts/Roboto-Regular.woff2') format('woff2'); } /* Roboto Bold (700) */ @@ -24,7 +24,7 @@ font-style: normal; font-weight: 700; src: local('Roboto Bold'), local('Roboto-Bold'), - url('../res/fonts/Roboto-Bold.woff2') format('woff2'); + url('../assets/fonts/Roboto-Bold.woff2') format('woff2'); } /* =================== @@ -33,8 +33,6 @@ :root { /* Colors */ - --color-statusbar: #E0E0E0; - --color-appbar: #F5F5F5; --color-background: #FAFAFA; --color-card: #FFF; --color-grey: #9E9E9E; @@ -62,7 +60,7 @@ --curve-sharp: cubic-bezier(0.4, 0.0, 0.6, 1); /* ease in out */ - /* Shadows */ + /* Shadows (from MaterializeCSS) */ --shadow-depth-1: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12), 0 3px 1px -2px rgba(0, 0, 0, 0.2); --shadow-depth-2: 0 4px 5px 0 rgba(0, 0, 0, 0.14), 0 1px 10px 0 rgba(0, 0, 0, 0.12), 0 2px 4px -1px rgba(0, 0, 0, 0.3); --shadow-depth-3: 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0, 0, 0, 0.12), 0 3px 5px -1px rgba(0, 0, 0, 0.3); @@ -252,7 +250,6 @@ button[type='submit'] svg { display: inline-block; padding: 0.25rem 0.5rem; font-size: 0.8em; - letter-spacing: 0.5px; border-radius: var(--corner-radius); color: var(--font-color-light); background-color: var(--font-color-dark); diff --git a/app/styles/index.css b/app/styles/index.css index a171e3b..0e534c7 100644 --- a/app/styles/index.css +++ b/app/styles/index.css @@ -10,25 +10,24 @@ body::after { left: 0; width: 100%; height: 100%; - opacity: 0; - transition: opacity 0.5s var(--curve-standard); - will-change: opacity; + background-color: transparent; + /* No transitions are added here to prioritize reliability */ + will-change: background-color; } body.hide::after { background-color: var(--color-background); - opacity: 1; } body.dim::after { - background-color: black; - opacity: 0.5; + background-color: rgba(0, 0, 0, 0.5); } #counter { --color-counter: var(--color-grey); color: var(--color-counter); - font-size: 25em; + /* Making sure font has same width on all screens since it will be changing constantly */ + font-size: 25vw; will-change: color; } @@ -51,15 +50,15 @@ body.dim::after { /* This will never trigger because a header is not focusable by default. It is * still declared just in case this behaviour changes. */ #counter.end:focus { - color: var(--color-primary-light); + --color-counter: var(--color-primary-light); } #counter.end:hover { - color: var(--color-primary); + --color-counter: var(--color-primary); } #counter.end:active { - color: var(--color-primary-dark); + --color-counter: var(--color-primary-dark); } #progress { @@ -95,5 +94,5 @@ body.dim::after { } #info { - font-size: 1.5em; + font-size: 2em; } diff --git a/app/views/index.html b/app/views/index.html index 759139f..a64c6ea 100644 --- a/app/views/index.html +++ b/app/views/index.html @@ -3,7 +3,7 @@ Station Timer - + @@ -39,6 +39,24 @@

Restart

+
+ +

Mute

+
+ +
+ +

Unmute

+
+