2 Commits
7 changed files with 140 additions and 24 deletions
+18 -10
View File
@@ -19,18 +19,24 @@ Morocco (Ministry of Habous), matching AlAdhan `method=21`:
All computation happens **on the watch** — no GPS, no network. Pure solar math
(same Meeus approach as the sundial) lives in `src/common/prayer.js`.
## Screen (v0.3)
## Screen (v0.5)
- Pure #000000 AMOLED background, including the list and prayer rows
- Larger 42px clock, 28px prayer names and 36px times
- Native vertically swipe-scrollable list: 80px rows in a 320×240px viewport
- Top/bottom spacers let the first and last prayers scroll to the center
- Fixed next-prayer countdown; lavender outline/text highlight the next prayer
- Normal system back button exits; no crown support assumed
- 15s refresh; no changes to prayer calculations
- Pure #000000 AMOLED background, large 50px clock, no swipe hint
- Prayer names above their times, in fixed 104px native list rows
- After scrolling stops, the nearest-center prayer gets a 60px time and 28px
label; other rows use 30px times and 20px labels
- No font resizing or row-array replacement during gesture/inertial scrolling
- Duplicate stop notifications do not repaint unchanged focus; periodic refresh
defers while scrolling and resumes normally after hiding/showing the app
- Scroll viewport 320×264 at x73/y101 (center y233); 80px end spacers
- Fixed countdown below the list; lavender next-prayer highlight
- Normal system back exits; calculation logic unchanged
Build and automated tests pass; v0.3 scrolling/rendering needs an on-watch check.
`preview-0.3.png` is a static layout mock with illustrative times, not a watch screenshot.
User reported watch lag in v0.4. A 500-event VM replay measured 500 row-array
replacements in v0.4 versus zero during scrolling in v0.5. This verifies reduced
update work, NOT device frame rate. All automated tests and release build pass;
on-watch smoothness still needs confirmation. `preview-0.4.png` remains an
illustrative static layout mock; the settled appearance is unchanged.
## Project layout
@@ -56,6 +62,8 @@ npm run release
node tests/prayer.cjs
node tests/page.cjs
node tests/amoled.cjs
node tests/focus.cjs
node tests/scroll-performance.cjs
```
## Install
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+2 -2
View File
@@ -2,8 +2,8 @@
"package": "com.ilias.salah",
"name": "SALAH",
"icon": "/common/icon.png",
"versionName": "0.3",
"versionCode": 3,
"versionName": "0.5",
"versionCode": 5,
"minAPILevel": 1,
"deviceTypeList": [
"watch"
+40 -12
View File
@@ -6,15 +6,14 @@
<text class="bannerLabel">{{ nextName }} in</text>
<text class="bannerTime">{{ countdown }}</text>
</div>
<list class="prayers" id="prayers">
<list class="prayers" id="prayers" onscroll="onScroll" onscrollend="onScrollEnd">
<list-item type="spacer" class="spacer"><div></div></list-item>
<list-item type="prayer" for="{{ row in rows }}" tid="key" class="row {{ row.key === nextKey ? 'rowNext' : '' }}">
<text class="rowName {{ row.key === nextKey ? 'nameNext' : '' }}">{{ row.label }}</text>
<text class="rowTime {{ row.key === nextKey ? 'timeNext' : '' }}">{{ row.time }}</text>
<text style="{{ row.nameStyle }}" class="rowName {{ row.key === nextKey ? 'nameNext' : '' }}">{{ row.label }}</text>
<text style="{{ row.timeStyle }}" class="rowTime {{ row.key === nextKey ? 'timeNext' : '' }}">{{ row.time }}</text>
</list-item>
<list-item type="spacer" class="spacer"><div></div></list-item>
</list>
<text class="hint">SWIPE TO SCROLL</text>
</div>
</template>
<script>
@@ -47,6 +46,7 @@ export default {
},
onInit() {},
onShow() {
this._scrolling = false
this.update()
this.timer = setInterval(() => this.update(), 15000) // 15s cadence
},
@@ -56,7 +56,35 @@ export default {
onDestroy() {
clearInterval(this.timer)
},
onScroll(event) {
if (!event || typeof event.scrollY !== 'number' || !isFinite(event.scrollY)) return
// Non-template bookkeeping only while the native list is moving.
this._scrollY = Math.max(0, Math.min(520, event.scrollY))
this._scrolling = event.scrollState !== 0
if (event.scrollState === 0) this.onScrollEnd()
},
onScrollEnd() {
this._scrolling = false
this.applyFocus()
},
applyFocus() {
const focused = Math.round((this._scrollY || 0) / 104)
let changed = false
const rows = this.rows.map((row, index) => {
const timeSize = index === focused ? '60px' : '30px'
const nameSize = index === focused ? '28px' : '20px'
if (row.timeStyle && row.timeStyle.fontSize === timeSize &&
row.nameStyle && row.nameStyle.fontSize === nameSize) return row
changed = true
return Object.assign({}, row, {
timeStyle: { fontSize: timeSize },
nameStyle: { fontSize: nameSize }
})
})
if (changed) this.rows = rows
},
update() {
if (this._scrolling) return
const now = new Date()
const hh = String(now.getHours()).padStart(2, '0')
const mm = String(now.getMinutes()).padStart(2, '0')
@@ -77,23 +105,23 @@ export default {
passed: t[key] < nowMin,
rowStyle: { width: ROW_WIDTHS[i] + 'px' }
}))
this.applyFocus()
}
}
</script>
<style>
.page { width: 466px; height: 466px; background-color: #000000; }
.city { position: absolute; top: 26px; left: 103px; width: 260px; height: 26px; color: #aaa5b5; font-size: 20px; text-align: center; }
.clock { position: absolute; top: 52px; left: 103px; width: 260px; height: 48px; color: #ffffff; font-size: 42px; text-align: center; }
.banner { position: absolute; top: 104px; left: 53px; width: 360px; height: 36px; background-color: #000000; flex-direction: row; justify-content: center; align-items: center; }
.city { position: absolute; top: 12px; left: 103px; width: 260px; height: 26px; color: #aaa5b5; font-size: 20px; text-align: center; }
.clock { position: absolute; top: 35px; left: 103px; width: 260px; height: 56px; color: #ffffff; font-size: 50px; text-align: center; }
.banner { position: absolute; top: 383px; left: 53px; width: 360px; height: 36px; background-color: #000000; flex-direction: row; justify-content: center; align-items: center; }
.bannerLabel { color: #c5a3ff; font-size: 24px; margin-right: 8px; }
.bannerTime { color: #ffffff; font-size: 26px; font-weight: bold; }
.prayers { position: absolute; top: 146px; left: 73px; width: 320px; height: 240px; flex-direction: column; background-color: #000000; }
.prayers { position: absolute; top: 101px; left: 73px; width: 320px; height: 264px; flex-direction: column; background-color: #000000; }
.spacer { width: 320px; height: 80px; background-color: #000000; }
.row { width: 320px; height: 80px; background-color: #000000; flex-direction: row; justify-content: space-between; align-items: center; padding-left: 10px; padding-right: 10px; }
.row { width: 320px; height: 104px; background-color: #000000; flex-direction: column; justify-content: center; align-items: center; padding-left: 10px; padding-right: 10px; }
.rowNext { border-width: 1px; border-color: #a779ef; border-radius: 18px; }
.rowName { color: #d0cbd6; font-size: 28px; }
.rowName { width: 298px; height: 30px; text-align: center; color: #d0cbd6; font-size: 28px; }
.nameNext { color: #c5a3ff; font-weight: bold; }
.rowTime { color: #ffffff; font-size: 36px; }
.rowTime { width: 298px; height: 70px; text-align: center; color: #ffffff; font-size: 60px; }
.timeNext { color: #c5a3ff; font-weight: bold; }
.hint { position: absolute; top: 403px; left: 103px; width: 260px; height: 24px; color: #aaa5b5; font-size: 18px; text-align: center; }
</style>
+40
View File
@@ -0,0 +1,40 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const src = fs.readFileSync('src/pages/index/index.ux', 'utf8')
const body = src.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/import\s+\w+\s+from\s+'[^']*';?/, '').replace('export default', 'globalThis.spec =')
const ctx = {prayer: require('../src/common/prayer.js'), setInterval: () => 1, clearInterval: () => {}}
vm.createContext(ctx); vm.runInContext(body, ctx)
const p = Object.assign({}, ctx.spec, JSON.parse(JSON.stringify(ctx.spec.private)))
p.update()
assert.equal(typeof p.onScroll, 'function', 'scroll handler drives center magnification')
const size = i => parseFloat(p.rows[i].timeStyle.fontSize)
p.onScroll({scrollY: 0, scrollState: 0})
assert.equal(size(0), 60, 'first centered prayer gets 60px time')
assert.ok(size(1) < size(0))
p.onScroll({scrollY: 104, scrollState: 0})
assert.equal(size(1), 60)
assert.equal(size(0), size(2), 'symmetric shrink around center')
p.onScroll({scrollY: 52, scrollState: 0})
assert.equal(size(1), 60, 'nearest row wins after stopping halfway')
assert.equal(size(0), 30)
p.onScroll({scrollY: 520, scrollState: 0})
assert.equal(size(5), 60, 'last prayer can be magnified')
p.update()
assert.equal(size(5), 60, 'clock refresh preserves scroll focus')
for (const offset of [-100, 9999]) {
p.onScroll({scrollY: offset, scrollState: 0})
p.rows.forEach(row => assert.ok(parseFloat(row.timeStyle.fontSize) >= 30 && parseFloat(row.timeStyle.fontSize) <= 60))
}
const before = JSON.stringify(p.rows)
for (const event of [undefined, {}, {scrollY: NaN}, {scrollY: Infinity}]) p.onScroll(event)
assert.equal(JSON.stringify(p.rows), before, 'invalid events do not corrupt styles')
assert.ok(!src.includes('SWIPE TO SCROLL'))
assert.match(src, /onscroll="onScroll"/)
assert.match(src, /style="\{\{ row.timeStyle \}\}"/)
assert.match(src, /style="\{\{ row.nameStyle \}\}"/)
function css(c,k) { return parseFloat(src.match(new RegExp('\\.'+c+'\\s*\\{([^}]+)'))[1].match(new RegExp(k+':\\s*([^;]+)'))[1]) }
assert.equal(css('prayers','top') + css('prayers','height') / 2, 233, 'magnification center is physical screen center')
assert.equal(css('row','height'), 104, 'fixed row geometry prevents scroll feedback')
assert.ok(css('clock','font-size') > 42, 'header clock enlarged too')
console.log('Center magnification, settled focus, end rows, refresh preservation, invalid events and wiring passed')
+2
View File
@@ -94,6 +94,8 @@ function page() {
p.onHide = spec.onHide
p.onDestroy = spec.onDestroy
p.update = spec.update
p.applyFocus = spec.applyFocus
p.onScroll = spec.onScroll
p.rows = []
return p
}
+38
View File
@@ -0,0 +1,38 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const src = fs.readFileSync('src/pages/index/index.ux', 'utf8')
const body = src.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/import\s+\w+\s+from\s+'[^']*';?/, '').replace('export default', 'globalThis.spec =')
const ctx = {prayer: require('../src/common/prayer.js'), setInterval: () => 1, clearInterval: () => {}}
vm.createContext(ctx); vm.runInContext(body, ctx)
const p = Object.assign({}, ctx.spec, JSON.parse(JSON.stringify(ctx.spec.private)))
p.update()
let rows = p.rows, writes = 0
Object.defineProperty(p, 'rows', {get: () => rows, set: value => {writes++; rows = value}})
const original = JSON.stringify(rows)
for (let i=0; i<500; i++) p.onScroll({scrollY: i, scrollState: i < 250 ? 1 : 2})
console.log('Row-array replacements during 500 gesture/inertia events:', writes)
assert.equal(writes, 0, 'native scrolling must not rebuild reactive rows')
assert.equal(JSON.stringify(rows), original, 'no font changes while scrolling')
p.update()
assert.equal(writes, 0, 'periodic refresh must defer during scrolling')
p.onScroll({scrollY: 520, scrollState: 0})
assert.equal(parseFloat(p.rows[5].timeStyle.fontSize), 60, 'last row grows after stop')
assert.ok(writes <= 1, 'one focus update at stop')
const settled = writes
p.onScrollEnd()
assert.equal(writes, settled, 'duplicate stop event is a no-op')
p.onScroll({scrollY: 208, scrollState: 1})
p.onScrollEnd()
assert.equal(parseFloat(p.rows[2].timeStyle.fontSize), 60, 'scrollend fallback applies nearest row')
assert.equal(parseFloat(p.rows[1].timeStyle.fontSize), 30)
const saved = JSON.stringify(p.rows)
for (const e of [undefined, {}, {scrollY: NaN}, {scrollY: Infinity}]) p.onScroll(e)
assert.equal(JSON.stringify(p.rows), saved)
p.onScroll({scrollY: 312, scrollState: 1})
p.onHide()
p.onShow()
assert.equal(p._scrolling, false, 'hiding during a swipe must not freeze refresh after resume')
assert.equal(parseFloat(p.rows[3].timeStyle.fontSize), 60)
assert.match(src, /onscrollend="onScrollEnd"/)
console.log('Scroll workload, deferred refresh, settled focus and duplicate-event checks passed')