ML-176.2: research and implementation plan
This commit is contained in:
+655
@@ -0,0 +1,655 @@
|
|||||||
|
---
|
||||||
|
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 if needed
|
||||||
|
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 = "..."
|
||||||
|
while (display.measure_text("Search: " + search_query + display_ellipsis, scale=1) > max_w
|
||||||
|
and len(search_query) > 0):
|
||||||
|
search_query = search_query[:-1]
|
||||||
|
label = "Search: " + search_query + display_ellipsis if search_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)`.
|
||||||
|
|
||||||
|
#### 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)
|
||||||
|
```
|
||||||
|
|
||||||
|
**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. Tapping a record opens detail. Empty/error states work. 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.
|
||||||
|
|
||||||
|
**Verification:** All touch handlers dispatch correctly. No crashes on state transitions. Scroll works in search results. Record taps in search results open detail.
|
||||||
|
|
||||||
|
### 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. |
|
||||||
+40
-3
@@ -4,15 +4,20 @@ title: "Presto: add splash screen, search views, and on-screen keyboard"
|
|||||||
status: To Do
|
status: To Do
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: "2026-05-10 13:35"
|
created_date: "2026-05-10 13:35"
|
||||||
|
updated_date: "2026-05-10 13:56"
|
||||||
labels:
|
labels:
|
||||||
- presto
|
- presto
|
||||||
- search
|
- search
|
||||||
- micropython
|
- micropython
|
||||||
milestone: m-0
|
milestone: m-0
|
||||||
dependencies: []
|
dependencies:
|
||||||
|
- ML-176.1
|
||||||
references:
|
references:
|
||||||
- presto/AGENTS.md (constraints and patterns)
|
- presto/AGENTS.md
|
||||||
- presto/README.md (API contract)
|
- presto/README.md
|
||||||
|
- >-
|
||||||
|
doc-17 -
|
||||||
|
Implementation-Plan-ML-176.2-—-Presto-splash-screen-search-views-and-on-screen-keyboard
|
||||||
modified_files:
|
modified_files:
|
||||||
- presto/records_on_the_day.py
|
- presto/records_on_the_day.py
|
||||||
parent_task_id: ML-176
|
parent_task_id: ML-176
|
||||||
@@ -113,3 +118,35 @@ Refactor `presto/records_on_the_day.py` into a unified app with a splash screen,
|
|||||||
- [ ] #18 Keyboard renders correctly at 720×720: all keys visible and tappable
|
- [ ] #18 Keyboard renders correctly at 720×720: all keys visible and tappable
|
||||||
- [ ] #19 Back-to-Home navigation available from month view and search views
|
- [ ] #19 Back-to-Home navigation available from month view and search views
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
Full plan: `doc-17 - Implementation Plan: ML-176.2 — Presto splash screen, search views, and on-screen keyboard`
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
11-step plan, all within `records_on_the_day.py`:
|
||||||
|
|
||||||
|
1. **State constants + globals** — Add `STATE_HOME` (4), `STATE_SEARCH_INPUT` (5), `STATE_SEARCH_RESULTS` (6). Remove `STATE_STARTUP`. Add `search_query`, `search_results`, `keyboard_mode`, `previous_state` globals.
|
||||||
|
2. **`fetch_search_results()`** — New API client calling `GET /api/v1/collection?q=<encoded>&limit=20`. Returns `(records_list, error_flag)`. Depends on ML-176.1 for the backend `q` parameter.
|
||||||
|
3. **Splash screen (`STATE_HOME`)** — `draw_home_screen()` with two large buttons: "Search Collection" and "Today's Records".
|
||||||
|
4. **On-screen keyboard (`STATE_SEARCH_INPUT`)** — `draw_search_input()` with 3-row QWERTY + numbers keyboard. Hit-test via `_keyboard_hit_test()`. Touch handler `handle_search_input_touch()`. Visual key-press feedback. OK button dimmed when query empty.
|
||||||
|
5. **Search results (`STATE_SEARCH_RESULTS`)** — Reuses `_draw_record_row()`. Own scroll state (`search_scroll_offset`, `_search_content_height`). `prepare_records_for_display(recs=None)` made generic to support both `records` and `search_results`. Empty/error states.
|
||||||
|
6. **Navigation tracking** — `previous_state` tracks whether detail was entered from day or search. Back from detail returns to correct origin.
|
||||||
|
7. **Back-to-Home** — Home button added to month header. `wake_display()` sets `state = STATE_HOME`. `redraw_current_view()` updated for new states.
|
||||||
|
8. **Boot flow** — Starts at `STATE_HOME`, no auto-fetch. User taps "Today's Records" → month view.
|
||||||
|
9. **Main loop dispatch** — Added handlers for all new states. `handle_home_touch()`. Search results drag-scroll mirrors day view.
|
||||||
|
10. **Cleanup** — Remove all `STATE_STARTUP` references.
|
||||||
|
11. **Deploy** — Verify ML-176.1 deployed → `mise run presto` → manual verification on device.
|
||||||
|
|
||||||
|
### Key design decisions
|
||||||
|
|
||||||
|
- "Today's Records" navigates to month view (with today highlighted), not directly to day — preserves calendar context
|
||||||
|
- Keyboard: fixed layout, lowercase only, no shift/caps, max 50 chars
|
||||||
|
- Search results: 20 per page, no infinite scroll. Own scroll state per AGENTS.md rules.
|
||||||
|
- `prepare_records_for_display()` parameterized to accept optional list — avoids duplicating the entire display-prep pipeline
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|||||||
Reference in New Issue
Block a user