├── TH3-screenshot.png
├── resources
├── bklight.png
├── ico_cal.png
├── ico_active.png
├── ico_azonem.png
├── ico_batt.png
├── ico_climb.png
├── ico_dist.png
├── ico_steps.png
├── widget.defs
├── widgets.gui
├── styles.css
├── styles~348x250.css
├── styles~336x336.css
├── index.gui
└── index.view
├── README.md
├── LICENSE
├── companion
└── index.js
├── settings
└── index.jsx
└── app
└── index.js
/TH3-screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tyuen/THE-watchface/HEAD/TH3-screenshot.png
--------------------------------------------------------------------------------
/resources/bklight.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tyuen/THE-watchface/HEAD/resources/bklight.png
--------------------------------------------------------------------------------
/resources/ico_cal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tyuen/THE-watchface/HEAD/resources/ico_cal.png
--------------------------------------------------------------------------------
/resources/ico_active.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tyuen/THE-watchface/HEAD/resources/ico_active.png
--------------------------------------------------------------------------------
/resources/ico_azonem.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tyuen/THE-watchface/HEAD/resources/ico_azonem.png
--------------------------------------------------------------------------------
/resources/ico_batt.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tyuen/THE-watchface/HEAD/resources/ico_batt.png
--------------------------------------------------------------------------------
/resources/ico_climb.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tyuen/THE-watchface/HEAD/resources/ico_climb.png
--------------------------------------------------------------------------------
/resources/ico_dist.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tyuen/THE-watchface/HEAD/resources/ico_dist.png
--------------------------------------------------------------------------------
/resources/ico_steps.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tyuen/THE-watchface/HEAD/resources/ico_steps.png
--------------------------------------------------------------------------------
/resources/widget.defs:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/resources/widgets.gui:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # THE watchface for Fitbit Ionic/Versa/Versa Lite/Versa 3/Sense
2 |
3 | 
4 |
5 | Features:
6 | - Customize the color of the minute hand and rings
7 | - Tap to cycle between the numerical stats
8 | - Customize which numerical stats are shown
9 | - 4 rings to show steps, distance, calories and floors climbs (becomes active minutes on Versa Lite)
10 | - Battery indicator under the week name
11 | - Direct link to app store (open with phone):
12 | https://gam.fitbit.com/gallery/clock/cc129d5e-ec49-40f0-8f2e-7f5f08ab3095
13 |
--------------------------------------------------------------------------------
/resources/styles.css:
--------------------------------------------------------------------------------
1 | line {
2 | stroke-linecap: round;
3 | }
4 | #mydate {
5 | font-size: 26;
6 | font-family: System-Regular;
7 | text-anchor: middle;
8 | text-length: 2;
9 | x: 82%;
10 | y: 50%+10;
11 | }
12 |
13 | #myweek {
14 | font-size: 26;
15 | font-family: System-Regular;
16 | text-anchor: middle;
17 | text-length: 20;
18 | x: 23%;
19 | y: 50%+10;
20 | }
21 |
22 | #mystats {
23 | font-size: 32;
24 | font-family: System-Bold;
25 | text-anchor: middle;
26 | text-length: 12;
27 | y: 76%+10;
28 | }
29 | #batt_bars {
30 | transform: translate(23%,0);
31 | }
32 | #batt0 {
33 | stroke-linecap: butt;
34 | y1: 50%+19;
35 | y2: 50%+19;
36 | }
37 | #batt {
38 | stroke-linecap: butt;
39 | y1: 50%+19;
40 | y2: 50%+19;
41 | }
42 |
--------------------------------------------------------------------------------
/resources/styles~348x250.css:
--------------------------------------------------------------------------------
1 | line {
2 | stroke-linecap: round;
3 | }
4 | #mydate {
5 | font-size: 24;
6 | font-family: System-Regular;
7 | text-anchor: middle;
8 | text-length: 2;
9 | x: 73%;
10 | y: 50%+8;
11 | }
12 |
13 | #myweek {
14 | font-size: 24;
15 | font-family: System-Regular;
16 | text-anchor: middle;
17 | text-length: 20;
18 | x: 31%;
19 | y: 50%+8;
20 | }
21 |
22 | #mystats {
23 | font-size: 30;
24 | font-family: System-Bold;
25 | text-anchor: middle;
26 | text-length: 12;
27 | y: 76%+8;
28 | }
29 | #batt_bars {
30 | transform: translate(31%,0);
31 | }
32 | #batt0 {
33 | stroke-linecap: butt;
34 | y1: 50%+17;
35 | y2: 50%+17;
36 | }
37 | #batt {
38 | stroke-linecap: butt;
39 | y1: 50%+17;
40 | y2: 50%+17;
41 | }
42 |
--------------------------------------------------------------------------------
/resources/styles~336x336.css:
--------------------------------------------------------------------------------
1 | #face {
2 | x: 18;
3 | y: 18;
4 | width: 100%-36;
5 | height: 100%-36;
6 | }
7 |
8 | #corners {
9 | x: 24;
10 | y: 24;
11 | width: 100%-48;
12 | height: 100%-48;
13 | }
14 |
15 | line {
16 | stroke-linecap: round;
17 | }
18 |
19 | #mydate {
20 | font-size: 26;
21 | font-family: System-Regular;
22 | text-anchor: middle;
23 | text-length: 2;
24 | x: 82%;
25 | y: 50%+10;
26 | }
27 |
28 | #myweek {
29 | font-size: 26;
30 | font-family: System-Regular;
31 | text-anchor: middle;
32 | text-length: 20;
33 | x: 23%;
34 | y: 50%+10;
35 | }
36 |
37 | #mystats {
38 | font-size: 32;
39 | font-family: System-Bold;
40 | text-anchor: middle;
41 | text-length: 12;
42 | y: 76%+10;
43 | }
44 | #batt_bars {
45 | transform: translate(23%,0);
46 | }
47 | #batt0 {
48 | stroke-linecap: butt;
49 | y1: 50%+19;
50 | y2: 50%+19;
51 | }
52 | #batt {
53 | stroke-linecap: butt;
54 | y1: 50%+19;
55 | y2: 50%+19;
56 | }
57 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 Terry Yuen
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/companion/index.js:
--------------------------------------------------------------------------------
1 | import {settingsStorage as store} from "settings";
2 | import {peerSocket} from "messaging";
3 | import {me} from "companion";
4 | import {outbox} from "file-transfer";
5 | import {encode} from "cbor";
6 | import {locale} from "user-settings";
7 | import {device} from "peer";
8 |
9 | store.onchange = sendAll;
10 |
11 | peerSocket.onmessage = e => {
12 | if(e.data && e.data.getAll) sendAll();
13 | };
14 |
15 | if(me.launchReasons.settingsChanged) sendAll();
16 |
17 | function sendAll() {
18 | let obj = {
19 | theme: trim(store.getItem("theme") || "blue"),
20 | hideRings: (store.getItem("hideRings") === "true"),
21 | unboldStats: (store.getItem("unboldStats") === "true"),
22 | stats: ["none", "steps", "heart", "floors", "cals", "mins", "time"],
23 | firstStat: 0,
24 | days: getLocale()
25 | };
26 | if(store.getItem("stats")) {
27 | obj.stats = JSON.parse(store.getItem("stats")).values.map(n => n.value);
28 | }
29 | if(store.getItem("firstStat2")) {
30 | let value = JSON.parse(store.getItem("firstStat2")).values[0].value;
31 | for(let i = 0; i < obj.stats.length; i++) {
32 | if(value === obj.stats[i]) {
33 | obj.firstStat = i;
34 | break;
35 | }
36 | }
37 | }
38 | outbox.enqueue("settings2.txt", encode(obj));
39 | }
40 |
41 | function trim(s) {
42 | return (s.charAt && s.charAt(0) === '"') ? s.substr(1, s.length - 2) : s;
43 | }
44 |
45 | function getLocale() {
46 | try {
47 | new Date().toLocaleDateString("i");
48 | } catch(e) {
49 | let lang = locale.language.replace("_", "-");
50 | let days = [];
51 | for(let i = 0; i < 7; i++) {
52 | days.push(new Date(2000, 0, i + 2).toLocaleDateString(lang, {weekday: "short"}).toUpperCase().replace(".", ""));
53 | }
54 | return days;
55 | }
56 | }
57 |
58 | if(store.getItem("modelName") !== device.modelName) store.setItem("modelName", device.modelName);
59 |
--------------------------------------------------------------------------------
/settings/index.jsx:
--------------------------------------------------------------------------------
1 | function isNotEmpty(list) {
2 | list = list && JSON.parse(list);
3 | list = list && list.values;
4 | return list && (list.length === 1 ? list[0].value !== "none" : list.length > 1);
5 | }
6 |
7 | //stats = {selected: [1, 0, 2], values[{name:"None",value:"none"},{name:"Steps",value:"steps"},{name:"Heart Rate (bpm)",value:"heart"}]}
8 |
9 | //firstStat = {"values":[{"name":"Steps","value":"steps"}],"selected":[1]}
10 |
11 | function getClampedFirstStat(props) {
12 | let first = props.settingsStorage.getItem("firstStat2");
13 | let stats = props.settingsStorage.getItem("stats");
14 | if(first && stats) {
15 | return Math.min(JSON.parse(first).selected[0], JSON.parse(stats).selected.length - 1);
16 | }
17 | }
18 |
19 | registerSettingsPage(props => {
20 | let statsList = [
21 | {name: "None", value: "none"},
22 | {name: "Steps", value: "steps"},
23 | {name: "Heart Rate (bpm)", value: "heart"},
24 | {name: "Resting Heart Rate (- bpm -)", value: "rest"},
25 | {name: "Distance (km/mi)", value: "dist"},
26 | {name: "Floors Climbed (f)", value: "floors"},
27 | {name: "Calories Burned (cal)", value: "cals"},
28 | {name: "Active Zone Minutes (hh'mm'')", value: "mins"},
29 | {name: "Weight (kg/lb)", value: "weight"},
30 | {name: "Digital Time", value: "time"},
31 | {name: "Battery", value: "batt"}
32 | ];
33 |
34 | if(/Versa Lite/.test(props.settings.modelName)) {
35 | statsList = statsList.filter(n => n.value !== "floors");
36 | }
37 |
38 | return (
39 |
40 |
57 |
58 |
61 |
62 |
63 |
69 |
70 | {(isNotEmpty(props.settings.stats) ?
71 |
80 |
81 | );
82 | });
83 |
--------------------------------------------------------------------------------
/resources/index.gui:
--------------------------------------------------------------------------------
1 |
166 |
--------------------------------------------------------------------------------
/resources/index.view:
--------------------------------------------------------------------------------
1 |
168 |
--------------------------------------------------------------------------------
/app/index.js:
--------------------------------------------------------------------------------
1 | import clock from "clock";
2 | import document from "document";
3 | import * as health from "user-activity";
4 | import {HeartRateSensor} from "heart-rate";
5 | import {display} from "display";
6 | import {vibration} from "haptics";
7 | import {peerSocket} from "messaging";
8 | import {preferences, units} from "user-settings";
9 | import {me} from "appbit";
10 | import {user} from "user-profile";
11 | import * as fs from "fs";
12 | import {battery} from "power";
13 | import {decode} from "cbor";
14 | import {inbox} from "file-transfer";
15 |
16 | const THEMES = {
17 | red: ["F93535", "CC4848", "AB4545"],
18 | orange: ["FF970F", "DD7F23", "B3671D"],
19 | yellow: ["FFFF00", "E4DB4A", "C6BC1E"],
20 | green: ["14C610", "119E0E", "0D730B"],
21 | blue: ["6fa8e9", "5682b4", "32547a"],
22 | purple: ["E86FE9", "B455B5", "79327A"],
23 | navy: ["5555ff", "4444ff", "4444ff"],
24 | grey: ["888888", "666666", "444444"],
25 | white: ["FFFFFF", "FFFFFF", "FFFFFF"]
26 | };
27 | const HOUR12 = (preferences.clockDisplay === "12h");
28 | const PROFILE = me.permissions.granted("access_user_profile");
29 | const NOCLIMB = (health.today.local.elevationGain === undefined);
30 |
31 | let weekNames = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
32 |
33 | let lastUpdatedRings = 0;
34 | let lastUpdatedHeart = 0;
35 | let showRings = true;
36 | let unboldStats = false;
37 |
38 | let stats = NOCLIMB ? ["none", "steps", "heart", "cals", "mins", "time"] : ["none", "steps", "heart", "floors", "cals", "mins", "time"];
39 | let firstStat = 0; //0=blank
40 | let curStat = 0;
41 | let heartSensor;
42 |
43 | let myDate = $("mydate");
44 | let myWeek = $("myweek");
45 | let myHours = $("hours");
46 | let myMins = $("minutes");
47 | let mySecs = $("seconds");
48 | let myStats = $("mystats");
49 | let myBatt = $("batt");
50 |
51 | let myRingTL = $("today_tl");
52 | let myRingTR = $("today_tr");
53 | let myRingBL = $("today_bl");
54 | let myRingBR = $("today_br");
55 |
56 | function $(s) {
57 | return document.getElementById(s);
58 | }
59 |
60 | function onTick(now) {
61 | now = (now && now.date) || new Date();
62 | myDate.text = now.getDate();
63 | myWeek.text = weekNames[now.getDay()];
64 |
65 | let hours = now.getHours() % 12;
66 | let mins = now.getMinutes();
67 | let secs = now.getSeconds();
68 | myHours.groupTransform.rotate.angle = (hours + mins/60)*30;
69 | myMins.groupTransform.rotate.angle = mins*6;
70 | mySecs.groupTransform.rotate.angle = secs*6;
71 |
72 | myBatt.x2 = Math.round(battery.chargeLevel*7/25) - 14;
73 |
74 | if(showRings || (stats.length > 0 && stats[curStat] !== "none")) {
75 | let nowTime = now.getTime();
76 |
77 | if(showRings) {
78 | if(nowTime - lastUpdatedRings > 30000) {
79 | lastUpdatedRings = nowTime;
80 | let today = health.today.adjusted;
81 | let goal = health.goals;
82 | updateRing(myRingTL, "cal", goal, today);
83 | updateRing(myRingTR, "step", goal, today);
84 | updateRing(myRingBR, "dist", goal, today);
85 | if(NOCLIMB) {
86 | updateRing(myRingBL, "active", goal, today);
87 | } else {
88 | updateRing(myRingBL, "climb", goal, today);
89 | }
90 | }
91 | }
92 |
93 | if(stats.length > 0 && stats[curStat] !== "none" && !display.aodActive) {
94 | if(stats[curStat] !== "heart") {
95 | updateStat();
96 | } else {
97 | if(nowTime - lastUpdatedHeart > 1600) {
98 | lastUpdatedHeart = nowTime;
99 | updateHeart();
100 | }
101 | }
102 | } else {
103 | myStats.text = "";
104 | }
105 | }
106 | }
107 |
108 | function setAOD(on) {
109 | if(on) {
110 | clock.granularity = "minutes";
111 | mySecs.style.display = "none";
112 | myStats.style.display = "none";
113 | } else {
114 | clock.granularity = "seconds";
115 | mySecs.style.display = "inline";
116 | myStats.style.display = "inline";
117 | }
118 | }
119 |
120 | if(display.aodAvailable && me.permissions.granted("access_aod")) {
121 | display.aodAllowed = true;
122 | display.onchange = () => {
123 | setAOD(display.aodActive);
124 | if(!display.aodActive) onTick();
125 | };
126 | setAOD(display.aodActive);
127 | } else {
128 | clock.granularity = "seconds";
129 | }
130 |
131 | clock.ontick = onTick;
132 | onTick();
133 |
134 | $("top_half").onclick = () => {
135 | if(!display.aodEnabled) {
136 | if(display.autoOff === true) {
137 | display.autoOff = false;
138 | if(!display.autoOff) $("bklight").style.display = "inline";
139 | } else {
140 | display.autoOff = true;
141 | if(display.autoOff) $("bklight").style.display = "none";
142 | }
143 | }
144 | };
145 |
146 | $("btm_half").onclick = () => {
147 | if(stats.length > 0) {
148 | curStat = (curStat + 1) % stats.length;
149 | if(stats[curStat] === "heart") {
150 | updateHeart();
151 | } else {
152 | updateStat();
153 | }
154 | }
155 | };
156 |
157 | function updateRing(node, holder, goal, today) {
158 | let angle = 0;
159 | if(holder === "cal") {
160 | angle = (today.calories || 0)*360/(goal.calories || 400);
161 | } else if(holder === "step") {
162 | angle = (today.steps || 0)*360/(goal.steps || 10000);
163 | } else if(holder === "dist") {
164 | angle = (today.distance || 0)*360/(goal.distance || 7200);
165 | } else if(holder === "climb") {
166 | angle = (today.elevationGain || 0)*360/(goal.elevationGain || 20);
167 | } else if(holder === "active") {
168 | angle = (today.activeZoneMinutes.total || 0)*360/(goal.activeZoneMinutes.total || 30);
169 | }
170 | node.sweepAngle = Math.min(360, Math.round(angle));
171 | }
172 |
173 | function updateStat() {
174 | let today = health.today.adjusted;
175 | switch(stats[curStat]) {
176 | case "steps":
177 | myStats.text = today.steps; break;
178 | case "heart":
179 | break;
180 | case "dist":
181 | myStats.text = (units.distance === "metric") ? round(today.distance/1000) + " km" : round(today.distance/1609.34) + " mi";
182 | break;
183 | case "floors":
184 | myStats.text = today.elevationGain + " f";
185 | break;
186 | case "cals":
187 | myStats.text = today.calories + " cal";
188 | break;
189 | case "mins":
190 | let t = today.activeZoneMinutes.total;
191 | myStats.text = Math.floor(t/60) + "' " + pad(t % 60) + '"';
192 | break;
193 | case "time":
194 | let t = new Date();
195 | let hr = t.getHours();
196 | myStats.text = ((hr > 12 && HOUR12) ? hr % 12 : hr) + ":" + pad(t.getMinutes());
197 | break;
198 | case "weight":
199 | myStats.text = !PROFILE ? "No Access" : (units.weight === "metric" ? round(user.weight) + " kg" : round(user.weight/2.2046) + " lb");
200 | break;
201 | case "rest":
202 | myStats.text = !PROFILE ? "No Access" : "- " + user.restingHeartRate + " -";
203 | break;
204 | case "batt":
205 | myStats.text = battery.chargeLevel + "%";
206 | break;
207 | default: myStats.text = "";
208 | }
209 | }
210 |
211 | function pad(n) {
212 | return n < 10 ? "0" + n : n;
213 | }
214 |
215 | function round(n) {
216 | n = n.toFixed(2);
217 | if(n.substr(-2) === "00") return n.substr(0, n.length - 3);
218 | if(n.substr(-1) === "0") return n.substr(0, n.length - 1);
219 | return n;
220 | }
221 |
222 | var delayHeart;
223 |
224 | function updateHeart() {
225 | let h = heartSensor;
226 | if(!h) {
227 | heartSensor = h = new HeartRateSensor();
228 | h.onreading = () => {
229 | setTimeout(() => h.stop(), 100);
230 | clearTimeout(delayHeart);
231 | myStats.text = h.heartRate;
232 | };
233 | h.onerror = () => {
234 | setTimeout(() => h.stop(), 100);
235 | clearTimeout(delayHeart);
236 | myStats.text = "--";
237 | };
238 | }
239 | if(!h.activated) {
240 | clearTimeout(delayHeart);
241 | delayHeart = setTimeout(() => {
242 | myStats.text = "--";
243 | }, 500);
244 | h.start();
245 | }
246 | }
247 |
248 | function applySettings(o) {
249 | if(o.theme) {
250 | let colors = THEMES[o.theme] || [];
251 | for(let i = 0; i < colors.length; i++) {
252 | let nodes = document.getElementsByClassName("color" + (i + 1));
253 | let node, j = 0;
254 | while(node = nodes[j++]) node.style.fill = "#" + colors[i];
255 | }
256 | }
257 | if(o.days) {
258 | weekNames = o.days;
259 | }
260 | if("hideRings" in o) {
261 | showRings = !o.hideRings;
262 | let nodes = document.getElementsByClassName("rings");
263 | let node, j = 0;
264 | while(node = nodes[j++]) node.style.display = showRings ? "inline" : "none";
265 | }
266 | if("unboldStats" in o) {
267 | unboldStats = o.unboldStats;
268 | myStats.style.fontFamily = unboldStats ? "System-Regular" : "System-Bold";
269 | }
270 | if("stats" in o) stats = o.stats;
271 | if("firstStat" in o) curStat = firstStat = Math.min(o.firstStat, stats.length - 1);
272 | myStats.text = "";
273 | lastUpdatedRings = 0;
274 | lastUpdatedHeart = 0;
275 | }
276 |
277 | function parseFile(name) {
278 | let obj;
279 | try {
280 | obj = fs.readFileSync(name, "cbor");
281 | } catch(e) {
282 | return true;
283 | }
284 |
285 | if(name === "settings2.txt") {
286 | if(obj) applySettings(obj);
287 | }
288 | }
289 |
290 | function pendingFiles() {
291 | let found = false;
292 | let temp;
293 | while(temp = inbox.nextFile()) {
294 | parseFile(temp);
295 | found = true;
296 | }
297 | if(found) {
298 | display.poke();
299 | vibration.start("bump");
300 | }
301 | }
302 |
303 | pendingFiles();
304 | inbox.onnewfile = pendingFiles;
305 |
306 | if(parseFile("settings2.txt")) {
307 | let done = (peerSocket.readyState === peerSocket.OPEN);
308 | if(done) {
309 | peerSocket.send({getAll: 1});
310 | } else {
311 | peerSocket.onopen = () => {
312 | if(!done) peerSocket.send({getAll: 1});
313 | done = true;
314 | };
315 | }
316 | }
317 |
318 | if(NOCLIMB) {
319 | $("floors").href = "ico_azonem.png";
320 | }
321 |
--------------------------------------------------------------------------------