716 lines
32 KiB
Markdown
716 lines
32 KiB
Markdown
---
|
||
id: doc-17
|
||
title: >-
|
||
Implementation Plan: ML-176.2 — Presto splash screen, search views, and
|
||
on-screen keyboard
|
||
type: specification
|
||
created_date: "2026-05-10 13:48"
|
||
updated_date: "2026-05-10 13:56"
|
||
tags:
|
||
- presto
|
||
- search
|
||
- micropython
|
||
- implementation-plan
|
||
---
|
||
|
||
# Implementation Plan — ML-176.2: Presto splash screen, search views, and on-screen keyboard
|
||
|
||
## 0. Dependencies
|
||
|
||
- **ML-176.1** (In Progress): API accepts `q` parameter on `GET /api/v1/collection`. Must be completed before `fetch_search_results()` works end-to-end, but Presto-side implementation can proceed in parallel — the `q` parameter is additive (absent → same as current behavior). Presto code can be written and syntax-checked independently.
|
||
|
||
## 1. Objective Alignment
|
||
|
||
The current `records_on_the_day.py` is a single-purpose calendar app that boots directly into today's records. The objective is to refactor it into a multi-screen app: a **splash screen** (home) with two entry points ("Search Collection" and "Today's Records"), a **search input view** with a custom on-screen QWERTY/numbers keyboard, and a **search results view** that reuses the existing record-list rendering infrastructure. The app must also navigate correctly (back from detail returns to the origin screen) and always return to the splash screen on display wake.
|
||
|
||
Each section below maps a concrete implementation step to a verifiable outcome.
|
||
|
||
## 2. Simplicity and Alternatives Considered
|
||
|
||
**Chosen approach:** Everything stays in the single `records_on_the_day.py` file. No new modules, no class refactor. Use flat state constants and new drawing/touch-handler functions. Reuse existing `_draw_record_list()`, `_draw_record_row()`, `prepare_records_for_display()`, `draw_record_detail()`, and scroll infrastructure verbatim — search results is just `STATE_DAY` with a different header and data source.
|
||
|
||
**Alternatives evaluated and rejected:**
|
||
|
||
- _Separate files per screen_ — adds import complexity on MicroPython, increases flash fragmentation risk. Rejected.
|
||
- _Class-based screen hierarchy_ — class allocation and method dispatch add memory overhead. Flat function dispatch keeps GC pressure low and is consistent with existing code. Rejected.
|
||
- _Virtual keyboard as a component module_ — same fragmentation argument. Inline in the main file keeps everything in one compilation unit. Rejected.
|
||
- _Shift/caps support on keyboard_ — task explicitly says lowercase only. Deferred.
|
||
- _Infinite scroll for search results_ — task explicitly says 20 per page, no infinite scroll. Rejected.
|
||
|
||
## 3. Completeness and Sequencing
|
||
|
||
### Step 1: Add new state constants and global variables
|
||
|
||
Add at the top of the state globals section (after `STATE_RECORD = 3`):
|
||
|
||
```python
|
||
STATE_HOME = 4
|
||
STATE_SEARCH_INPUT = 5
|
||
STATE_SEARCH_RESULTS = 6
|
||
```
|
||
|
||
Change default `state = STATE_HOME` (was `STATE_STARTUP`).
|
||
|
||
Add new globals:
|
||
|
||
```python
|
||
search_query = "" # Current search buffer (max ~50 chars)
|
||
search_results = [] # Records from search API
|
||
search_results_error = False
|
||
search_scroll_offset = 0 # Separate scroll offset for search results
|
||
_search_content_height = 0 # Separate content height cache for search results
|
||
keyboard_mode = "alpha" # "alpha" or "numbers"
|
||
previous_state = None # Track where we came from for detail view back
|
||
```
|
||
|
||
Remove `STATE_STARTUP = 0`. Replace all references to `STATE_STARTUP` with `STATE_HOME`.
|
||
|
||
**Verification:** `python3 -c "import py_compile; py_compile.compile('records_on_the_day.py', cfile='/tmp/records_on_the_day.pyc', doraise=True)"` passes.
|
||
|
||
### Step 2: Add `fetch_search_results()` API client function
|
||
|
||
**Depends on: ML-176.1** (backend `q` parameter support).
|
||
|
||
Add after `fetch_records()` in the API section:
|
||
|
||
```python
|
||
def fetch_search_results(query):
|
||
"""Fetch search results from the API.
|
||
Returns (records_list, error_flag). Same shape as fetch_records().
|
||
"""
|
||
# urequests doesn't have url-encode built-in; hand-encode common chars
|
||
encoded = query.replace(" ", "+")
|
||
url = API_BASE + "/api/v1/collection?q=" + encoded + "&limit=20"
|
||
|
||
try:
|
||
resp = urequests.get(url, headers=_auth_header())
|
||
if resp.status_code == 200:
|
||
data = resp.json()
|
||
recs = data.get("records", [])
|
||
resp.close()
|
||
gc.collect()
|
||
return recs, False
|
||
else:
|
||
resp.close()
|
||
gc.collect()
|
||
return [], True
|
||
except Exception:
|
||
gc.collect()
|
||
return [], True
|
||
```
|
||
|
||
**Verification:** After ML-176.1 is deployed, tapping OK on keyboard with a valid query shows results. Tapping OK with no network shows "Could not reach server".
|
||
|
||
### Step 3: Implement STATE_HOME (splash screen)
|
||
|
||
#### 3a. Layout constants
|
||
|
||
```python
|
||
HOME_BUTTON_W = WIDTH - 80
|
||
HOME_BUTTON_H = 90
|
||
HOME_BUTTON_X = 40
|
||
HOME_BUTTON1_Y = 220
|
||
HOME_BUTTON2_Y = HOME_BUTTON1_Y + HOME_BUTTON_H + 30
|
||
HOME_TITLE_Y = 100
|
||
HOME_TITLE = "Music Library"
|
||
```
|
||
|
||
#### 3b. Drawing function `draw_home_screen()`
|
||
|
||
```python
|
||
def draw_home_screen():
|
||
display.set_pen(_pen_bg)
|
||
display.clear()
|
||
|
||
display.set_pen(_pen_title)
|
||
display.set_font("bitmap14_outline")
|
||
tw = display.measure_text(HOME_TITLE, scale=1)
|
||
display.text(HOME_TITLE, (WIDTH - tw) // 2, HOME_TITLE_Y, scale=1)
|
||
|
||
_draw_home_button(HOME_BUTTON1_Y, "Search Collection")
|
||
_draw_home_button(HOME_BUTTON2_Y, "Today's Records")
|
||
|
||
presto.update()
|
||
|
||
def _draw_home_button(y, label):
|
||
display.set_pen(_pen_cell_bg)
|
||
display.rectangle(HOME_BUTTON_X, y, HOME_BUTTON_W, HOME_BUTTON_H)
|
||
display.set_pen(_pen_header_text)
|
||
display.set_font("bitmap8")
|
||
tw = display.measure_text(label, scale=1)
|
||
tx = HOME_BUTTON_X + (HOME_BUTTON_W - tw) // 2
|
||
ty = y + (HOME_BUTTON_H - 8) // 2
|
||
display.text(label, tx, ty, scale=1)
|
||
```
|
||
|
||
**Verification:** On boot, splash screen renders with two buttons. Tapping "Today's Records" transitions to STATE_MONTH. Tapping "Search Collection" transitions to STATE_SEARCH_INPUT.
|
||
|
||
### Step 4: Implement on-screen keyboard (STATE_SEARCH_INPUT)
|
||
|
||
#### 4a. Keyboard layout constants
|
||
|
||
```python
|
||
KB_MARGIN = 20
|
||
KB_KEY_GAP = 4
|
||
KB_KEY_H = 48
|
||
KB_KEY_W = (WIDTH - 2 * KB_MARGIN - 9 * KB_KEY_GAP) // 10 # computed from 10-key alpha row
|
||
|
||
KB_INPUT_Y = 30
|
||
KB_INPUT_H = 40
|
||
KB_INPUT_MARGIN = 40
|
||
KB_ROWS_START_Y = KB_INPUT_Y + KB_INPUT_H + 16
|
||
|
||
ALPHA_KEYS = [
|
||
list("qwertyuiop"),
|
||
list("asdfghjkl"),
|
||
list("zxcvbnm"),
|
||
]
|
||
|
||
NUM_KEYS = [
|
||
list("1234567890"),
|
||
list("!@#$%^&*()_"),
|
||
]
|
||
```
|
||
|
||
#### 4b. Drawing function `draw_search_input()`
|
||
|
||
```python
|
||
def draw_search_input():
|
||
display.set_pen(_pen_bg)
|
||
display.clear()
|
||
|
||
# Header with "Search Collection" title and Back (→ Home) button
|
||
_draw_search_input_header()
|
||
|
||
# Query text field
|
||
display.set_pen(_pen_cell_bg)
|
||
display.rectangle(KB_INPUT_MARGIN, KB_INPUT_Y, WIDTH - 2 * KB_INPUT_MARGIN, KB_INPUT_H)
|
||
display.set_pen(_pen_header_text)
|
||
display.set_font("bitmap8")
|
||
display.text(search_query, KB_INPUT_MARGIN + 8, KB_INPUT_Y + 12, scale=1)
|
||
|
||
display.set_font("bitmap8")
|
||
keys_matrix = ALPHA_KEYS if keyboard_mode == "alpha" else NUM_KEYS
|
||
toggle_label = "123" if keyboard_mode == "alpha" else "ABC"
|
||
|
||
cy = KB_ROWS_START_Y
|
||
for row_keys in keys_matrix:
|
||
num_keys = len(row_keys)
|
||
total_gap = (num_keys - 1) * KB_KEY_GAP
|
||
total_key_w = num_keys * KB_KEY_W
|
||
row_start_x = (WIDTH - total_key_w - total_gap) // 2
|
||
for i, char in enumerate(row_keys):
|
||
kx = row_start_x + i * (KB_KEY_W + KB_KEY_GAP)
|
||
_draw_key(kx, cy, char, KB_KEY_W, KB_KEY_H)
|
||
cy += KB_KEY_H + KB_KEY_GAP
|
||
|
||
# Bottom row: toggle | space | backspace | OK
|
||
bottom_w = (WIDTH - 2 * KB_MARGIN - 3 * KB_KEY_GAP) // 4
|
||
bx = KB_MARGIN
|
||
_draw_key(bx, cy, toggle_label, bottom_w, KB_KEY_H)
|
||
bx += bottom_w + KB_KEY_GAP
|
||
_draw_key(bx, cy, "Space", bottom_w, KB_KEY_H)
|
||
bx += bottom_w + KB_KEY_GAP
|
||
_draw_key(bx, cy, "<-", bottom_w, KB_KEY_H)
|
||
bx += bottom_w + KB_KEY_GAP
|
||
ok_pen = _pen_dim_text if search_query == "" else _pen_header_text
|
||
_draw_key(bx, cy, "OK", bottom_w, KB_KEY_H, text_pen=ok_pen)
|
||
|
||
presto.update()
|
||
|
||
def _draw_key(x, y, label, w, h, text_pen=None):
|
||
display.set_pen(_pen_cell_bg)
|
||
display.rectangle(x, y, w, h)
|
||
pen = text_pen if text_pen is not None else _pen_normal_text
|
||
display.set_pen(pen)
|
||
tw = display.measure_text(label, scale=1)
|
||
tx = x + (w - tw) // 2
|
||
ty = y + (h - 8) // 2
|
||
display.text(label, tx, ty, scale=1)
|
||
```
|
||
|
||
#### 4c. Keyboard touch handler `handle_search_input_touch(x, y)`
|
||
|
||
```python
|
||
def handle_search_input_touch(x, y):
|
||
global search_query, keyboard_mode, state, search_results, search_results_error
|
||
global search_scroll_offset, _search_content_height, previous_state
|
||
|
||
# Header Back button → Home
|
||
if BACK_X <= x <= BACK_X + BACK_W and BACK_Y <= y <= BACK_Y + BACK_H:
|
||
state = STATE_HOME
|
||
draw_home_screen()
|
||
return
|
||
|
||
action = _keyboard_hit_test(x, y)
|
||
if action is None:
|
||
return
|
||
|
||
if action == "toggle":
|
||
keyboard_mode = "numbers" if keyboard_mode == "alpha" else "alpha"
|
||
draw_search_input()
|
||
return
|
||
|
||
if action == "space":
|
||
if len(search_query) < 50:
|
||
search_query += " "
|
||
draw_search_input()
|
||
return
|
||
|
||
if action == "backspace":
|
||
search_query = search_query[:-1]
|
||
draw_search_input()
|
||
return
|
||
|
||
if action == "ok":
|
||
if not search_query.strip():
|
||
return
|
||
draw_status("Searching for:\n" + search_query)
|
||
if not ensure_wifi_connected():
|
||
search_results_error = True
|
||
search_results = []
|
||
state = STATE_SEARCH_RESULTS
|
||
draw_search_results()
|
||
return
|
||
recs, had_error = fetch_search_results(search_query)
|
||
search_results = recs
|
||
search_results_error = had_error
|
||
search_scroll_offset = 0
|
||
if not had_error and recs:
|
||
prepare_records_for_display(recs)
|
||
previous_state = STATE_SEARCH_INPUT
|
||
state = STATE_SEARCH_RESULTS
|
||
draw_search_results()
|
||
return
|
||
|
||
# Character key
|
||
if len(search_query) < 50 and len(action) == 1:
|
||
search_query += action
|
||
draw_search_input()
|
||
```
|
||
|
||
#### 4d. Hit-testing function `_keyboard_hit_test(x, y)`
|
||
|
||
```python
|
||
def _keyboard_hit_test(x, y):
|
||
if y < KB_ROWS_START_Y:
|
||
return None
|
||
|
||
keys_matrix = ALPHA_KEYS if keyboard_mode == "alpha" else NUM_KEYS
|
||
|
||
cy = KB_ROWS_START_Y
|
||
for row_keys in keys_matrix:
|
||
num_keys = len(row_keys)
|
||
total_gap = (num_keys - 1) * KB_KEY_GAP
|
||
total_key_w = num_keys * KB_KEY_W
|
||
row_start_x = (WIDTH - total_key_w - total_gap) // 2
|
||
for i, char in enumerate(row_keys):
|
||
kx = row_start_x + i * (KB_KEY_W + KB_KEY_GAP)
|
||
if kx <= x < kx + KB_KEY_W and cy <= y < cy + KB_KEY_H:
|
||
return char
|
||
cy += KB_KEY_H + KB_KEY_GAP
|
||
|
||
# Bottom row
|
||
bottom_w = (WIDTH - 2 * KB_MARGIN - 3 * KB_KEY_GAP) // 4
|
||
bx = KB_MARGIN
|
||
if bx <= x < bx + bottom_w and cy <= y < cy + KB_KEY_H:
|
||
return "toggle"
|
||
bx += bottom_w + KB_KEY_GAP
|
||
if bx <= x < bx + bottom_w and cy <= y < cy + KB_KEY_H:
|
||
return "space"
|
||
bx += bottom_w + KB_KEY_GAP
|
||
if bx <= x < bx + bottom_w and cy <= y < cy + KB_KEY_H:
|
||
return "backspace"
|
||
bx += bottom_w + KB_KEY_GAP
|
||
if bx <= x < bx + bottom_w and cy <= y < cy + KB_KEY_H:
|
||
return "ok"
|
||
|
||
return None
|
||
```
|
||
|
||
#### 4e. Key press visual feedback
|
||
|
||
Brief highlight flash on key press. Call `_flash_key()` before the action is applied; the subsequent `draw_search_input()` overwrites it:
|
||
|
||
```python
|
||
def _flash_key(kx, ky, kw, kh, label):
|
||
_draw_key(kx, ky, label, kw, kh, text_pen=_pen_today_text)
|
||
display.set_pen(_pen_arrow)
|
||
display.rectangle(kx, ky, kw, kh)
|
||
presto.update()
|
||
time.sleep(0.05)
|
||
```
|
||
|
||
**Verification:** All keys appear on a 720×720 display. Tapping letter keys appends to the query field. Backspace removes last char. 123/ABC toggles layouts. OK dim when empty and no-ops. OK with text triggers API call.
|
||
|
||
### Step 5: Implement STATE_SEARCH_RESULTS
|
||
|
||
#### 5a. Drawing function `draw_search_results()`
|
||
|
||
Reuses `_draw_record_row()` from the day view. Unique elements: search-specific header, own scroll state, empty/error states.
|
||
|
||
```python
|
||
def draw_search_results():
|
||
global search_scroll_offset
|
||
display.set_pen(_pen_bg)
|
||
display.clear()
|
||
|
||
_draw_search_results_header()
|
||
|
||
if search_results_error:
|
||
_draw_search_error()
|
||
presto.update()
|
||
return
|
||
|
||
if not search_results:
|
||
_draw_search_empty()
|
||
presto.update()
|
||
return
|
||
|
||
_draw_search_record_list()
|
||
presto.update()
|
||
|
||
def _draw_search_results_header():
|
||
display.set_pen(_pen_cell_bg)
|
||
display.rectangle(0, DAY_HEADER_Y, WIDTH, DAY_HEADER_H)
|
||
|
||
# Back button
|
||
bx, by = BACK_X, BACK_Y
|
||
display.set_pen(_pen_placeholder)
|
||
display.rectangle(bx, by, BACK_W, BACK_H)
|
||
display.set_pen(_pen_back)
|
||
display.set_font("bitmap8")
|
||
_draw_centered_text("<", bx, by, BACK_W, BACK_H, DAY_COUNT_TEXT_H)
|
||
|
||
# Title "Search: <query>" — truncate a temporary copy for display only.
|
||
# Never mutate the global search_query (the user refines it on Back).
|
||
display.set_pen(_pen_header_text)
|
||
display.set_font("bitmap14_outline")
|
||
label = "Search: " + search_query
|
||
max_w = WIDTH - (BACK_X + BACK_W + 8) - 8
|
||
if display.measure_text(label, scale=1) > max_w:
|
||
display_ellipsis = "..."
|
||
display_query = search_query
|
||
while (display.measure_text("Search: " + display_query + display_ellipsis, scale=1) > max_w
|
||
and len(display_query) > 0):
|
||
display_query = display_query[:-1]
|
||
label = "Search: " + display_query + display_ellipsis if display_query else "Search: ..."
|
||
tw = display.measure_text(label, scale=1)
|
||
display.text(label, (WIDTH - tw)//2,
|
||
DAY_HEADER_Y + (DAY_HEADER_H - DAY_HEADER_TEXT_H)//2, scale=1)
|
||
```
|
||
|
||
#### 5b. Search record list scroll (own state)
|
||
|
||
```python
|
||
def _draw_search_record_list():
|
||
global search_scroll_offset
|
||
max_offset = _search_max_scroll_offset()
|
||
search_scroll_offset = min(max(0, search_scroll_offset), max_offset)
|
||
|
||
if search_scroll_offset > 0:
|
||
display.set_pen(_pen_arrow)
|
||
display.set_font("bitmap14_outline")
|
||
display.text("^", WIDTH // 2 - 8, RECORD_START_Y - 28, scale=1)
|
||
|
||
cy = RECORD_START_Y - search_scroll_offset
|
||
for rec in search_results:
|
||
row_h = rec.get("_row_height", THUMB_SIZE + 8)
|
||
row_bottom = cy + row_h
|
||
if row_bottom <= RECORD_START_Y:
|
||
cy = row_bottom
|
||
continue
|
||
if cy >= HEIGHT - 28:
|
||
break
|
||
_draw_record_row(rec, cy)
|
||
cy = row_bottom
|
||
|
||
if search_scroll_offset < max_offset:
|
||
display.set_pen(_pen_arrow)
|
||
display.set_font("bitmap14_outline")
|
||
display.text("v", WIDTH // 2 - 8, HEIGHT - 24, scale=1)
|
||
|
||
def _search_max_scroll_offset():
|
||
viewport_h = HEIGHT - RECORD_START_Y - 28
|
||
return max(0, _search_content_height - viewport_h)
|
||
```
|
||
|
||
#### 5c. Generic `prepare_records_for_display(recs=None)`
|
||
|
||
Make the existing preparation pipeline accept an optional list so both `records` and `search_results` can use it:
|
||
|
||
```python
|
||
def prepare_records_for_display(recs=None):
|
||
global _content_height, _search_content_height, records, search_results
|
||
if recs is None:
|
||
recs = records
|
||
display.set_font("bitmap8")
|
||
if recs is search_results:
|
||
_search_content_height = 0
|
||
else:
|
||
_content_height = 0
|
||
for rec in recs:
|
||
_prepare_record_for_display(rec)
|
||
if recs is search_results:
|
||
_search_content_height += rec.get("_row_height", THUMB_SIZE + 8)
|
||
else:
|
||
_content_height += rec.get("_row_height", THUMB_SIZE + 8)
|
||
preload_record_thumbnails(recs)
|
||
gc.collect()
|
||
```
|
||
|
||
Similarly parameterize `preload_record_thumbnails(recs=None)`:
|
||
|
||
```python
|
||
def preload_record_thumbnails(recs=None):
|
||
"""Fetch thumbnail bytes before the first draw of the record list."""
|
||
if recs is None:
|
||
recs = records
|
||
for rec in recs:
|
||
if rec.get("micro_cover_url", ""):
|
||
_record_thumbnail_data(rec)
|
||
```
|
||
|
||
#### 5d. Empty and error states
|
||
|
||
```python
|
||
def _draw_search_empty():
|
||
display.set_pen(_pen_dim_text)
|
||
display.set_font("bitmap8")
|
||
msg = "No records found"
|
||
mw = display.measure_text(msg, scale=1)
|
||
display.text(msg, (WIDTH - mw) // 2, HEIGHT // 2 - 10, scale=1)
|
||
|
||
def _draw_search_error():
|
||
display.set_pen(_pen_error)
|
||
display.set_font("bitmap8")
|
||
msg = "Could not reach server"
|
||
mw = display.measure_text(msg, scale=1)
|
||
display.text(msg, (WIDTH - mw) // 2, HEIGHT // 2 - 20, scale=1)
|
||
display.set_pen(_pen_dim_text)
|
||
hint = "Tap to retry"
|
||
hw = display.measure_text(hint, scale=1)
|
||
display.text(hint, (WIDTH - hw) // 2, HEIGHT // 2 + 10, scale=1)
|
||
|
||
def handle_search_results_touch(x, y):
|
||
"""Process a tap event in search results view.
|
||
Back button, error retry, and record row taps.
|
||
Row scroll is handled by the drag logic in the main loop."""
|
||
global state, search_results, search_results_error
|
||
global search_scroll_offset, selected_record_idx, detail_scroll_offset
|
||
global previous_state
|
||
|
||
# Back button → search input (preserves search_query so user can refine)
|
||
if BACK_X <= x <= BACK_X + BACK_W and BACK_Y <= y <= BACK_Y + BACK_H:
|
||
state = STATE_SEARCH_INPUT
|
||
draw_search_input()
|
||
return
|
||
|
||
# Error state: retry on tap anywhere
|
||
if search_results_error:
|
||
draw_status("Retrying search...")
|
||
if not ensure_wifi_connected():
|
||
search_results_error = True
|
||
search_results = []
|
||
draw_search_results()
|
||
return
|
||
recs, had_error = fetch_search_results(search_query)
|
||
search_results = recs
|
||
search_results_error = had_error
|
||
search_scroll_offset = 0
|
||
if not had_error and recs:
|
||
prepare_records_for_display(recs)
|
||
draw_search_results()
|
||
return
|
||
|
||
# Record row tap → detail view
|
||
if not search_results_error and search_results:
|
||
rec_idx = touch_to_record_index(y)
|
||
if rec_idx is not None:
|
||
selected_record_idx = rec_idx
|
||
detail_scroll_offset = 0
|
||
previous_state = STATE_SEARCH_RESULTS
|
||
state = STATE_RECORD
|
||
draw_record_detail()
|
||
```
|
||
|
||
**Verification:** Search results display with cover art, titles, artists, format, year — identical layout to day view. Back returns to STATE_SEARCH_INPUT with query preserved (original query is never truncated by header rendering). Tapping a record opens detail. Empty/error states work. Tapping in error state retries the search. Scroll works identically to day view.
|
||
|
||
### Step 6: Navigation state tracking for Back from detail
|
||
|
||
Track `previous_state` on transitions to `STATE_RECORD`:
|
||
|
||
- From `STATE_DAY` → `previous_state = STATE_DAY`
|
||
- From `STATE_SEARCH_RESULTS` → `previous_state = STATE_SEARCH_RESULTS`
|
||
|
||
On Back in `STATE_RECORD`, navigate to `previous_state` instead of always `STATE_DAY`.
|
||
|
||
**Verification:** Detail view Back → search results when entered from search. Back → day when entered from day.
|
||
|
||
### Step 7: Update existing views for Back-to-Home
|
||
|
||
#### 7a. Month view Home button
|
||
|
||
Add a "Home" button in the month header, leftmost position. Month header layout becomes: `[Home] [<] [Month Year] [>]`.
|
||
|
||
Update `_draw_month_header()` to draw the home button and shift the left arrow right. Update `handle_month_touch()` to check the home button first.
|
||
|
||
#### 7b. Display wake returns to home
|
||
|
||
In `wake_display()`, set `state = STATE_HOME` before calling `redraw_current_view()`.
|
||
|
||
#### 7c. `redraw_current_view()` updated
|
||
|
||
Add cases for `STATE_HOME`, `STATE_SEARCH_INPUT`, `STATE_SEARCH_RESULTS`.
|
||
|
||
**Verification:** From any view, after display sleep + wake, splash screen appears. Month view has Home button. Search input Back → Home.
|
||
|
||
### Step 8: Update `main()` globals and boot flow
|
||
|
||
Change boot flow:
|
||
|
||
1. `state = STATE_HOME`
|
||
2. After WiFi + NTP sync + `set_today()`, call `draw_home_screen()` — no auto-fetch of today's records
|
||
3. User must tap "Today's Records" to trigger the calendar flow
|
||
|
||
**Verification:** On cold boot, splash screen is the first thing shown. No API call until user taps a button.
|
||
|
||
### Step 9: Update main loop dispatch
|
||
|
||
Add handlers for `STATE_HOME`, `STATE_SEARCH_INPUT`, `STATE_SEARCH_RESULTS` in the main loop. Implement `handle_home_touch(x, y)` for "Search Collection" and "Today's Records" buttons. Implement drag-scroll for `STATE_SEARCH_RESULTS` mirroring day-view scroll. Wire `previous_state` tracking for detail navigation.
|
||
|
||
```python
|
||
def handle_home_touch(x, y):
|
||
global state
|
||
|
||
# "Search Collection"
|
||
if (HOME_BUTTON_X <= x <= HOME_BUTTON_X + HOME_BUTTON_W and
|
||
HOME_BUTTON1_Y <= y <= HOME_BUTTON1_Y + HOME_BUTTON_H):
|
||
state = STATE_SEARCH_INPUT
|
||
search_query = ""
|
||
keyboard_mode = "alpha"
|
||
draw_search_input()
|
||
return
|
||
|
||
# "Today's Records" → month view with today highlighted
|
||
if (HOME_BUTTON_X <= x <= HOME_BUTTON_X + HOME_BUTTON_W and
|
||
HOME_BUTTON2_Y <= y <= HOME_BUTTON2_Y + HOME_BUTTON_H):
|
||
view_year = today_year
|
||
view_month = today_month
|
||
selected_day = today_day
|
||
state = STATE_MONTH
|
||
draw_month_view()
|
||
return
|
||
```
|
||
|
||
**Design decision:** "Today's Records" goes to month view (with today highlighted) rather than directly to day view. This gives the user full calendar context — they can tap today or any other day. Consistent with the existing month-first navigation pattern.
|
||
|
||
**Main loop dispatch additions:**
|
||
|
||
- `STATE_HOME` → calls `handle_home_touch(x, y)`
|
||
- `STATE_SEARCH_INPUT` → calls `handle_search_input_touch(x, y)`, then `wait_for_touch_release()`
|
||
- `STATE_SEARCH_RESULTS` → checks back button / error tap via `handle_search_results_touch(x, y)`, then enters drag-scroll loop mirroring day-view logic (own `search_scroll_offset`, own `_search_content_height` cache)
|
||
- `STATE_RECORD` → update Back button handler to navigate to `previous_state` instead of always `STATE_DAY`; if `previous_state == STATE_SEARCH_RESULTS` call `draw_search_results()`, if `previous_state == STATE_DAY` call `draw_day_view()`
|
||
|
||
**Verification:** All touch handlers dispatch correctly. No crashes on state transitions. Scroll works in search results. Record taps in search results open detail. Tapping in search results error state retries the search.
|
||
|
||
### Step 10: Delete unused `STATE_STARTUP` references
|
||
|
||
Remove `STATE_STARTUP = 0`. Replace remaining references with `STATE_HOME`.
|
||
|
||
**Verification:** `grep -n "STATE_STARTUP" records_on_the_day.py` returns nothing.
|
||
|
||
### Step 11: Test and deploy
|
||
|
||
1. Syntax check: `python3 -c "import py_compile; py_compile.compile('records_on_the_day.py', cfile='/tmp/records_on_the_day.pyc', doraise=True)"`
|
||
2. Verify ML-176.1 is deployed to production
|
||
3. Deploy to Presto: `mise run presto`
|
||
4. Manual verification on device for all 19 acceptance criteria
|
||
|
||
## 4. Verifiability
|
||
|
||
| Step | Verification |
|
||
| ---- | ---------------------------------------------------------------------------------------- |
|
||
| 1 | Syntax check passes |
|
||
| 2 | Functional after ML-176.1 deployed; OK triggers API call, network error handled |
|
||
| 3 | Presto boots to splash screen; two buttons visible and tappable |
|
||
| 4 | All keys visible; typing works; backspace works; 123/ABC toggles; OK dim when empty |
|
||
| 5 | Search results render with cover art; scroll works; empty/error states display correctly |
|
||
| 6 | Back from detail → correct origin (search results or day view) |
|
||
| 7 | Home button on month view works; wake always returns to home |
|
||
| 8 | On cold boot, splash screen shows first |
|
||
| 9 | All touch handlers dispatch correctly; no crashes |
|
||
| 10 | No STATE_STARTUP references remain |
|
||
| 11 | All 19 ACs pass on physical device |
|
||
|
||
## 5. Architecture Impact Analysis
|
||
|
||
| Touchpoint | Impact |
|
||
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| `records_on_the_day.py` | Primary change. ~250–350 new lines for keyboard, splash, search views. Existing drawing functions reused. `prepare_records_for_display()` and `preload_record_thumbnails()` made generic (accept optional list param). |
|
||
| `collection_controller.ex` | **No change** — handled by ML-176.1 |
|
||
| `collection_json.ex` | **No change** — `index/1` already returns correct shape |
|
||
| `Collection.search_records/2` | **No change** — already accepts query string |
|
||
| Routes | **No change** — existing `GET /api/v1/collection` reused |
|
||
| PubSub | No impact |
|
||
| Supervision tree | No impact |
|
||
| External APIs | Search API call added to Presto. Same auth header, same error handling pattern as `fetch_records()` |
|
||
| Month view UI | Header gains Home button; left/right arrow positions shift right |
|
||
|
||
## 6. Performance Profile
|
||
|
||
- **Keyboard drawing:** O(1) — fixed ~34 keys drawn per keystroke. Each key is a rectangle + text call. No network calls during typing. Estimated <5ms per redraw on RP2350B at 150 MHz.
|
||
- **Search results rendering:** O(n) where n ≤ 20 records per page. Same scroll performance rules as day view: layout/text pre-computed in `prepare_records_for_display()`, drag scroll uses placeholders for thumbnails. No measurable difference from day view.
|
||
- **Memory:** Search query capped at 50 chars. Keyboard layout arrays ~200 bytes total (module-level constants). Search results reuse `search_results` list (same structure as `records`). GC pressure identical to current app.
|
||
- **Network:** One additional API call per search (same `urequests` pattern). No network calls during scroll or typing.
|
||
- **N+1 risks:** None. Both API endpoints return full record data in a single response. Thumbnails preloaded in batch.
|
||
- **Latency:** Same 1–3 second API call latency as day-view fetch. Keyboard is instant (local only).
|
||
|
||
## 7. Benchmarking Requirements
|
||
|
||
No formal benchmarks needed. The keyboard is a fixed-size static layout with O(1) redraw. Search results use the same rendering path as day view, already validated on hardware. The only quantitative check:
|
||
|
||
- **Keyboard tap-to-redraw latency:** Verify that tapping a key and seeing the character appear feels instant (<100ms). If noticeable lag occurs, profile with `time.ticks_ms()` around `draw_search_input()`.
|
||
|
||
Manual verification on the Presto device is sufficient.
|
||
|
||
## 8. Cost Profile
|
||
|
||
No additional paid resources:
|
||
|
||
- **API calls:** Search uses the same `GET /api/v1/collection` endpoint (ML-176.1). No third-party API calls. Incremental server load is one extra request per search, same magnitude as a day-view request.
|
||
- **Storage:** No new storage requirements. Keyboard layouts are constant strings in the MicroPython module.
|
||
- **Compute:** No additional server-side processing beyond what `search_records` already handles.
|
||
|
||
## 9. Production Infrastructure Steps
|
||
|
||
### Production Changes
|
||
|
||
**1. Deploy ML-176.1 backend change first**
|
||
|
||
```bash
|
||
git push origin main # triggers Coolify deploy (includes ML-176.1 commit)
|
||
```
|
||
|
||
Verify: `curl -H "Authorization: Bearer <token>" "https://music-library.claudio-ortolina.org/api/v1/collection?q=test&limit=1"` returns results.
|
||
|
||
**2. Deploy updated Presto firmware**
|
||
|
||
```bash
|
||
mise run presto
|
||
```
|
||
|
||
Rollback: copy the previous version of `records_on_the_day.py` to the device.
|
||
|
||
No environment variable changes, no service provisioning, no DNS changes, no firewall rules.
|
||
|
||
## 10. Documentation Updates
|
||
|
||
| File | Change needed |
|
||
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| `presto/README.md` | Add sections: "Splash Screen", "Search", "On-Screen Keyboard". Update "Features" list. Add API endpoint `GET /api/v1/collection?q=&limit=20` to "API Endpoint" section with response format. |
|
||
| `presto/AGENTS.md` | Update "State Management" section: add `STATE_HOME`, `STATE_SEARCH_INPUT`, `STATE_SEARCH_RESULTS`. Update "API Contract" section with search endpoint. Update "Scroll Performance" to mention per-view offset/cache discipline for search results. Add keyboard constraints: fixed layouts, no shift, max 50 chars. |
|
||
| `docs/architecture.md` | No change needed. |
|
||
| `docs/project-conventions.md` | No change needed. |
|
||
| `docs/production-infrastructure.md` | No change needed. |
|