From 592a615a5d0d0fae58ae69940985ead9728f10fc Mon Sep 17 00:00:00 2001 From: Stefan Koelle Date: Sun, 2 Aug 2026 10:38:10 +0200 Subject: [PATCH] Phase 5: detail screens (scrollable lists + back button) + main controller - weather_detail: scrollable hourly forecast (time, temp, description, rain probability), data logic ported from old weather_detail_screen.cpp - calendar_detail: scrollable list of all 10 events (summary + formatted date/all_day), ported from old calendar_detail_screen.cpp - mvg: scrollable departures list (line badge U/S colored, destination, time/delay, cancellation), 1-minute refresh timer ported from old mvg_screen.cpp - all three use back_button widget (fixed bottom-left) - main.cpp: Screen controller, navigates between screens via callbacks, 5-minute inactivity timeout -> home, per-screen tick() for refresh --- src/main.cpp | 101 +++++++++++----------------- src/ui/calendar_detail_screen.cpp | 77 ++++++++++++++++++++++ src/ui/calendar_detail_screen.h | 6 ++ src/ui/mvg_screen.cpp | 105 ++++++++++++++++++++++++++++++ src/ui/mvg_screen.h | 6 ++ src/ui/weather_detail_screen.cpp | 95 +++++++++++++++++++++++++++ src/ui/weather_detail_screen.h | 6 ++ 7 files changed, 332 insertions(+), 64 deletions(-) create mode 100644 src/ui/calendar_detail_screen.cpp create mode 100644 src/ui/calendar_detail_screen.h create mode 100644 src/ui/mvg_screen.cpp create mode 100644 src/ui/mvg_screen.h create mode 100644 src/ui/weather_detail_screen.cpp create mode 100644 src/ui/weather_detail_screen.h diff --git a/src/main.cpp b/src/main.cpp index d7bd0f8..c0dde59 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4,13 +4,27 @@ #include #include #include "display/display_setup.h" -#include "api/weather_api.h" -#include "api/calendar_api.h" -#include "api/departures_api.h" +#include "ui/screen_base.h" +#include "ui/home_screen.h" +#include "ui/weather_detail_screen.h" +#include "ui/calendar_detail_screen.h" +#include "ui/mvg_screen.h" namespace { unsigned long lastTickMs = 0; +unsigned long lastInteractionMs = 0; +const unsigned long IDLE_TIMEOUT_MS = 5UL * 60UL * 1000UL; + +Screen screens[4]; +ScreenId current = ScreenId::HOME; + +ScreenId navigate(ScreenId target) { + lastInteractionMs = millis(); + current = target; + screens[(int)target].show(); + return target; +} void connectWifi() { Serial.println("Verbinde mit WLAN..."); @@ -30,65 +44,6 @@ void connectWifi() { } } -// Phase 2 verification: fetch all three APIs once at boot and dump parsed -// fields to Serial so JSON parsing can be verified independently of the UI. -void verifyApisViaSerial() { - Serial.println("\n=== Wetter ==="); - WeatherData w = fetchWeather(); - Serial.printf("valid=%d temp=%d symbol=%s desc=%s forecast=%u\n", - w.valid, w.current.temperature, - w.current.symbol.c_str(), w.current.description.c_str(), - w.forecast.size()); - for (size_t i = 0; i < w.forecast.size() && i < 3; ++i) { - const auto &f = w.forecast[i]; - Serial.printf(" [%u] %s %dC %s precip=%.2f/%s\n", i, f.time.c_str(), - f.temperature, f.description.c_str(), - f.precipitationProbability, f.precipitationType.c_str()); - } - - Serial.println("\n=== Kalender ==="); - CalendarData c = fetchCalendar(); - Serial.printf("valid=%d events=%u\n", c.valid, c.events.size()); - for (size_t i = 0; i < c.events.size() && i < 3; ++i) { - const auto &e = c.events[i]; - Serial.printf(" [%u] %s | %s all_day=%d\n", i, e.summary.c_str(), - e.startAt.c_str(), e.allDay); - } - - Serial.println("\n=== MVG ==="); - DeparturesData d = fetchDepartures(); - Serial.printf("valid=%d departures=%u\n", d.valid, d.departures.size()); - for (size_t i = 0; i < d.departures.size() && i < 3; ++i) { - const auto &dep = d.departures[i]; - Serial.printf(" [%u] %s %s -> %s %s delay=%d cancel=%d\n", i, - dep.type.c_str(), dep.line.c_str(), - dep.destination.c_str(), dep.timeStr.c_str(), - dep.delayMin, dep.cancelled); - } - Serial.println("=== API verification done ===\n"); -} - -void btn_event_cb(lv_event_t *e) { - lv_obj_t *label = (lv_obj_t *)lv_event_get_user_data(e); - static int n = 0; - lv_label_set_text_fmt(label, "Taps: %d", ++n); -} - -void buildTestScreen() { - lv_obj_t *scr = lv_screen_active(); - lv_obj_set_style_bg_color(scr, Theme::bg(), 0); - - lv_obj_t *btn = lv_button_create(scr); - lv_obj_set_size(btn, 200, 80); - lv_obj_center(btn); - lv_obj_set_style_bg_color(btn, Theme::accentWeather(), 0); - - lv_obj_t *label = lv_label_create(btn); - lv_label_set_text(label, "Taps: 0"); - lv_obj_center(label); - lv_obj_add_event_cb(btn, btn_event_cb, LV_EVENT_CLICKED, label); -} - } // namespace void setup() { @@ -97,8 +52,19 @@ void setup() { Serial.println("WT32-SC01 Plus booting..."); display_init(); connectWifi(); - verifyApisViaSerial(); - buildTestScreen(); + + // Build all four screens and wire navigation callbacks. + screens[(int)ScreenId::HOME] = homeScreen_make(); + screens[(int)ScreenId::WEATHER_DETAIL] = weatherDetailScreen_make(); + screens[(int)ScreenId::CALENDAR_DETAIL] = calendarDetailScreen_make(); + screens[(int)ScreenId::MVG] = mvgScreen_make(); + homeScreen_setNavigator(navigate); + weatherDetailScreen_setNavigator(navigate); + calendarDetailScreen_setNavigator(navigate); + mvgScreen_setNavigator(navigate); + + lastInteractionMs = millis(); + navigate(ScreenId::HOME); Serial.println("Setup done."); } @@ -107,5 +73,12 @@ void loop() { if (now - lastTickMs >= 5) { lastTickMs = now; display_loop(); + screens[(int)current].tick(); + + // Inactivity: return to home after 5 minutes without input. + if (current != ScreenId::HOME && + now - lastInteractionMs >= IDLE_TIMEOUT_MS) { + navigate(ScreenId::HOME); + } } } diff --git a/src/ui/calendar_detail_screen.cpp b/src/ui/calendar_detail_screen.cpp new file mode 100644 index 0000000..cdeacd3 --- /dev/null +++ b/src/ui/calendar_detail_screen.cpp @@ -0,0 +1,77 @@ +#include "calendar_detail_screen.h" +#include "ui/widgets/back_button.h" +#include "api/calendar_api.h" +#include +#include +#include +#include + +namespace { + +ScreenId (*g_navigate)(ScreenId) = nullptr; +void on_back(lv_event_t *) { if (g_navigate) g_navigate(ScreenId::HOME); } + +CalendarData lastCalendar; +bool ok = false; +lv_obj_t *list = nullptr; + +void buildList() { + if (!list) return; + lv_obj_clean(list); + + if (!ok) { + lv_obj_t *lbl = lv_label_create(list); + lv_label_set_text(lbl, LV_SYMBOL_WARNING " Keine Verbindung"); + lv_obj_set_style_text_color(lbl, Theme::accentError(), 0); + return; + } + + for (const auto &ev : lastCalendar.events) { + lv_obj_t *row = lv_obj_create(list); + lv_obj_set_size(row, 280, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_all(row, 6, 0); + lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t *sum = lv_label_create(row); + lv_label_set_text(sum, sanitizeGermanText(ev.summary.substring(0, 30)).c_str()); + lv_obj_set_style_text_color(sum, Theme::text(), 0); + lv_obj_align(sum, LV_ALIGN_TOP_LEFT, 0, 0); + + lv_obj_t *when = lv_label_create(row); + lv_label_set_text(when, DateUtils::formatShortDE(ev.startAt, ev.allDay).c_str()); + lv_obj_set_style_text_color(when, Theme::accentCalendar(), 0); + lv_obj_align(when, LV_ALIGN_TOP_LEFT, 0, 20); + } +} + +} // namespace + +void calendarDetailScreen_setNavigator(ScreenId (*nav)(ScreenId)) { g_navigate = nav; } + +void calendarDetailScreen_create(Screen &s) { + list = lv_list_create(s.root); + lv_obj_set_pos(list, 0, 0); + lv_obj_set_size(list, 320, 480 - 80); + lv_obj_set_style_bg_color(list, Theme::bg(), 0); + lv_obj_set_style_border_width(list, 0, 0); + back_button_create(s.root, on_back, nullptr); +} + +void calendarDetailScreen_refresh(Screen &) { + CalendarData c = fetchCalendar(); + ok = c.valid; + if (c.valid) lastCalendar = c; + buildList(); +} + +void calendarDetailScreen_tick(Screen &) {} + +Screen calendarDetailScreen_make() { + Screen s; + s.create_fn = calendarDetailScreen_create; + s.refresh_fn = calendarDetailScreen_refresh; + s.tick_fn = calendarDetailScreen_tick; + return s; +} diff --git a/src/ui/calendar_detail_screen.h b/src/ui/calendar_detail_screen.h new file mode 100644 index 0000000..3a87a38 --- /dev/null +++ b/src/ui/calendar_detail_screen.h @@ -0,0 +1,6 @@ +#pragma once +#include "ui/screen_base.h" + +// Calendar detail: scrollable list of all 10 events + back button. +Screen calendarDetailScreen_make(); +void calendarDetailScreen_setNavigator(ScreenId (*nav)(ScreenId)); diff --git a/src/ui/mvg_screen.cpp b/src/ui/mvg_screen.cpp new file mode 100644 index 0000000..c21dba6 --- /dev/null +++ b/src/ui/mvg_screen.cpp @@ -0,0 +1,105 @@ +#include "mvg_screen.h" +#include "ui/widgets/back_button.h" +#include "api/departures_api.h" +#include +#include +#include + +namespace { + +ScreenId (*g_navigate)(ScreenId) = nullptr; +void on_back(lv_event_t *) { if (g_navigate) g_navigate(ScreenId::HOME); } + +DeparturesData lastData; +bool ok = false; +lv_obj_t *list = nullptr; +unsigned long lastFetchMs = 0; +const unsigned long REFRESH_INTERVAL_MS = 60UL * 1000UL; + +void buildList() { + if (!list) return; + lv_obj_clean(list); + + if (!ok) { + lv_obj_t *lbl = lv_label_create(list); + lv_label_set_text(lbl, LV_SYMBOL_WARNING " Keine Verbindung"); + lv_obj_set_style_text_color(lbl, Theme::accentError(), 0); + return; + } + + for (const auto &dep : lastData.departures) { + lv_obj_t *row = lv_obj_create(list); + lv_obj_set_size(row, 280, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_all(row, 6, 0); + lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); + + // Line badge (colored text: U red, S green). + String badge = String(dep.icon) + " " + dep.line; + lv_obj_t *ln = lv_label_create(row); + lv_label_set_text(ln, badge.c_str()); + lv_obj_set_style_text_color(ln, + dep.type == "UBAHN" ? Theme::accentUBahn() : Theme::accentSBahn(), 0); + lv_obj_align(ln, LV_ALIGN_TOP_LEFT, 0, 0); + + String dest = sanitizeGermanText(dep.destination.substring(0, 20)); + lv_obj_t *d = lv_label_create(row); + lv_label_set_text(d, dest.c_str()); + lv_obj_set_style_text_color(d, Theme::text(), 0); + lv_obj_align(d, LV_ALIGN_TOP_LEFT, 0, 18); + + String right; + if (dep.cancelled) { + right = "Ausfall"; + } else { + right = dep.timeStr; + if (dep.delayMin > 0) right += " +" + String(dep.delayMin); + } + lv_obj_t *t = lv_label_create(row); + lv_label_set_text(t, right.c_str()); + lv_obj_set_style_text_color(t, + dep.cancelled ? Theme::accentError() : Theme::text(), 0); + lv_obj_align(t, LV_ALIGN_TOP_RIGHT, 0, 18); + } +} + +void doFetch() { + DeparturesData d = fetchDepartures(); + ok = d.valid; + if (d.valid) lastData = d; + lastFetchMs = millis(); +} + +} // namespace + +void mvgScreen_setNavigator(ScreenId (*nav)(ScreenId)) { g_navigate = nav; } + +void mvgScreen_create(Screen &s) { + list = lv_list_create(s.root); + lv_obj_set_pos(list, 0, 0); + lv_obj_set_size(list, 320, 480 - 80); + lv_obj_set_style_bg_color(list, Theme::bg(), 0); + lv_obj_set_style_border_width(list, 0, 0); + back_button_create(s.root, on_back, nullptr); +} + +void mvgScreen_refresh(Screen &) { + doFetch(); + buildList(); +} + +void mvgScreen_tick(Screen &) { + if (lastFetchMs == 0 || millis() - lastFetchMs >= REFRESH_INTERVAL_MS) { + doFetch(); + buildList(); + } +} + +Screen mvgScreen_make() { + Screen s; + s.create_fn = mvgScreen_create; + s.refresh_fn = mvgScreen_refresh; + s.tick_fn = mvgScreen_tick; + return s; +} diff --git a/src/ui/mvg_screen.h b/src/ui/mvg_screen.h new file mode 100644 index 0000000..fe54335 --- /dev/null +++ b/src/ui/mvg_screen.h @@ -0,0 +1,6 @@ +#pragma once +#include "ui/screen_base.h" + +// MVG departures: scrollable list of all departures + back button. +Screen mvgScreen_make(); +void mvgScreen_setNavigator(ScreenId (*nav)(ScreenId)); diff --git a/src/ui/weather_detail_screen.cpp b/src/ui/weather_detail_screen.cpp new file mode 100644 index 0000000..07b9059 --- /dev/null +++ b/src/ui/weather_detail_screen.cpp @@ -0,0 +1,95 @@ +#include "weather_detail_screen.h" +#include "ui/widgets/back_button.h" +#include "api/weather_api.h" +#include +#include +#include + +namespace { + +ScreenId (*g_navigate)(ScreenId) = nullptr; +void on_back(lv_event_t *) { if (g_navigate) g_navigate(ScreenId::HOME); } + +WeatherData lastWeather; +bool ok = false; +lv_obj_t *list = nullptr; + +void buildList() { + if (!list) return; + lv_obj_clean(list); + + if (!ok) { + lv_obj_t *lbl = lv_label_create(list); + lv_label_set_text(lbl, LV_SYMBOL_WARNING " Keine Verbindung"); + lv_obj_set_style_text_color(lbl, Theme::accentError(), 0); + return; + } + + // Header row with current conditions. + String header = String(lastWeather.current.temperature) + " C " + + sanitizeGermanText(lastWeather.current.description); + lv_obj_t *h = lv_label_create(list); + lv_label_set_text(h, header.c_str()); + lv_obj_set_style_text_font(h, &lv_font_montserrat_20, 0); + lv_obj_set_style_text_color(h, Theme::text(), 0); + + for (const auto &fe : lastWeather.forecast) { + lv_obj_t *row = lv_obj_create(list); + lv_obj_set_size(row, 280, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_all(row, 6, 0); + lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); + + // time hh:mm + int tIdx = fe.time.indexOf('T'); + String hhmm = tIdx > 0 ? fe.time.substring(tIdx + 1, tIdx + 6) : fe.time; + + String left = hhmm + " " + String(fe.temperature) + "C"; + lv_obj_t *l = lv_label_create(row); + lv_label_set_text(l, left.c_str()); + lv_obj_set_style_text_color(l, Theme::text(), 0); + lv_obj_align(l, LV_ALIGN_TOP_LEFT, 0, 0); + + String desc = sanitizeGermanText(fe.description.substring(0, 16)); + if (fe.precipitationType == "rain" && fe.precipitationProbability > 0.0f) { + char buf[8]; + snprintf(buf, sizeof(buf), " %.0f%%", fe.precipitationProbability * 100); + desc += buf; + } + lv_obj_t *d = lv_label_create(row); + lv_label_set_text(d, desc.c_str()); + lv_obj_set_style_text_color(d, Theme::textDim(), 0); + lv_obj_align(d, LV_ALIGN_TOP_LEFT, 0, 18); + } +} + +} // namespace + +void weatherDetailScreen_setNavigator(ScreenId (*nav)(ScreenId)) { g_navigate = nav; } + +void weatherDetailScreen_create(Screen &s) { + list = lv_list_create(s.root); + lv_obj_set_pos(list, 0, 0); + lv_obj_set_size(list, 320, 480 - 80); + lv_obj_set_style_bg_color(list, Theme::bg(), 0); + lv_obj_set_style_border_width(list, 0, 0); + back_button_create(s.root, on_back, nullptr); +} + +void weatherDetailScreen_refresh(Screen &) { + WeatherData w = fetchWeather(); + ok = w.valid; + if (w.valid) lastWeather = w; + buildList(); +} + +void weatherDetailScreen_tick(Screen &) {} + +Screen weatherDetailScreen_make() { + Screen s; + s.create_fn = weatherDetailScreen_create; + s.refresh_fn = weatherDetailScreen_refresh; + s.tick_fn = weatherDetailScreen_tick; + return s; +} diff --git a/src/ui/weather_detail_screen.h b/src/ui/weather_detail_screen.h new file mode 100644 index 0000000..1a29fcb --- /dev/null +++ b/src/ui/weather_detail_screen.h @@ -0,0 +1,6 @@ +#pragma once +#include "ui/screen_base.h" + +// Weather detail: scrollable hourly forecast list + back button. +Screen weatherDetailScreen_make(); +void weatherDetailScreen_setNavigator(ScreenId (*nav)(ScreenId));