mirror of
https://github.com/skoelle/wt32sc01-dashboard.git
synced 2026-09-17 17:30:25 +00:00
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
This commit is contained in:
+37
-64
@@ -4,13 +4,27 @@
|
||||
#include <secrets.h>
|
||||
#include <theme.h>
|
||||
#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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
#include "calendar_detail_screen.h"
|
||||
#include "ui/widgets/back_button.h"
|
||||
#include "api/calendar_api.h"
|
||||
#include <theme.h>
|
||||
#include <text_utils.h>
|
||||
#include <date_utils.h>
|
||||
#include <lvgl.h>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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));
|
||||
@@ -0,0 +1,105 @@
|
||||
#include "mvg_screen.h"
|
||||
#include "ui/widgets/back_button.h"
|
||||
#include "api/departures_api.h"
|
||||
#include <theme.h>
|
||||
#include <text_utils.h>
|
||||
#include <lvgl.h>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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));
|
||||
@@ -0,0 +1,95 @@
|
||||
#include "weather_detail_screen.h"
|
||||
#include "ui/widgets/back_button.h"
|
||||
#include "api/weather_api.h"
|
||||
#include <theme.h>
|
||||
#include <text_utils.h>
|
||||
#include <lvgl.h>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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));
|
||||
Reference in New Issue
Block a user