Compare commits

...

2 Commits

Author SHA1 Message Date
rsgltzyd 6a6ee41a07 feat: 实现设置页面及其功能 2026-07-05 20:52:04 +08:00
rsgltzyd 9a355e540c feat: 添加 Settings 横屏页面实现规格文档 2026-07-05 20:33:26 +08:00
16 changed files with 1441 additions and 11 deletions
+765
View File
@@ -0,0 +1,765 @@
# Settings 横屏页面实现规格
> 项目:HeavenlyHeroStar
> 目标芯片:ESP32-S3
> ESP-IDF6.0.1
> LVGL9.2.2
> 逻辑分辨率:320×170(全局横屏)
> 状态:待实现(v1.2 修订稿)
## 1. 背景与目标
### 1.1 现状
当前 UI 层由 SquareLine Studio 生成,尚无 Settings 页面:
| 模块 | 路径 | 说明 |
|------|------|------|
| 物理分辨率 | `main/bsp/board_config.h` | `BOARD_LCD_H_RES=170`, `BOARD_LCD_V_RES=320`(竖屏面板) |
| LVGL Display | `main/bsp/lvgl_port.c` | 以 170×320 创建 display,无旋转 |
| 触摸 | `main/bsp/touch.c` | CST816S`swap_xy/mirror_x/mirror_y` 均为 0 |
| 背光 | `main/bsp/display.c` | `display_backlight_set_brightness(01023)` 已实现 |
| UI 屏幕 | `main/ui/screens/ui_Screen{1,2,3}.c` | Screen1 日历 170×320Screen3 为默认启动页 |
| 事件处理 | `main/app/ui_handlers.c` | HID 相关回调,与 SquareLine 生成的 `ui_events.c` 分离 |
| NVS | `main/app/app_main.c` | 已初始化 `nvs_flash`Settings 尚未使用 |
| WiFi | `main/services/wifi_manager.c` | `BOARD_WIFI_ENABLED=0`Network Tab 首版为占位 |
### 1.2 目标
实现 **Settings 设置页**,采用 **Master-Detail(主从)** 布局:
- 左侧 Sidebar90px):System / Display / Audio / Network 导航 + Save 按钮
- 右侧 Content230px):随 Tab 切换动态刷新的设置项
- **全局横屏**:启动后 LVGL 逻辑分辨率为 **320×170**,所有页面按横屏坐标设计
首版(MVP)交付:
- BSP 横屏基础设施(display 旋转 + touch 校准)
- Settings 屏幕骨架与 Tab 切换机制
- System Tab 完整功能(恢复出厂)
- Display Tab 亮度滑块(实时预览 + NVS 持久化)
- Audio / Network Tab 占位 UI + NVS 存根
- Save 按钮写入 NVSFactory Reset 恢复默认并重启
### 1.3 方案选型结论
| 方案 | 说明 | 结论 |
|------|------|------|
| A | SquareLine Studio 导出静态 Settings 屏 | 不利于 Tab 动态刷新,且易被重新导出覆盖 |
| **B** | **手写 C + `lv_obj_clean` 动态加载** | **采用**:模块化、可扩展、与 SquareLine 解耦 |
| C | 每个 Tab 独立 Screenlv_scr_load 切换 | 内存占用高,Sidebar 需重复创建 |
| 横屏-硬件 | 修改 ST7789 scan direction | 不采用:需改 panel 驱动,touch 仍须软件映射 |
| **横屏-软件** | **`lv_display_set_rotation(90°)`** | **采用**:改动集中在 BSP,UI 直接用 320×170 坐标 |
---
## 2. 页面布局
### 2.1 整体结构(320×170
**System Tab 选中时:**
```text
+---------------------------------------------------------------+
| Sidebar 90px | Content 230px |
| [System] * | System Settings --:-- |
| [Display] | ----------------------------------- |
| [Audio] | Enable Notification [switch OFF] |
| [Network] | |
| [Save] | [Factory Reset] |
+---------------------------------------------------------------+
|<-- 90px -->|<-------------------- 230px -------------------->|
```
**Display Tab 选中时(Content 区域示意):**
```text
| Display Settings --:-- |
| ----------------------------------- |
| Brightness: [=======> ] 70% |
```
### 2.2 区域参数表
| 区域名称 | 宽度 | 高度 | 位置 (x, y) | LVGL 控件 | 说明 |
|----------|------|------|-------------|-----------|------|
| Screen 根 | 320 | 170 | (0, 0) | `lv_obj_create(NULL)` | 禁用滚动 |
| Sidebar | 90 | 170 | (0, 0) | `lv_list` | 导航容器;显式设 `pad_all=2``item_gap=2` |
| Sidebar 导航按钮 ×4 | 86 | 28 | 相对 listy 递增 | `lv_list_add_button` | System/Display/Audio/Network |
| Save 按钮 | 86 | 28 | (2, 138) | `lv_button` | 固定底部,不在 list 内滚动 |
| Content 容器 | 230 | 170 | (90, 0) | `lv_obj` | 动态刷新区域 |
| StatusBar | 230 | 25 | (0, 0) 相对 content | `lv_obj` + `lv_label` ×2 | 标题左、时间右 |
| SettingsBody | 230 | 145 | (0, 25) 相对 content | switch/slider/button | 各 Tab 内容 |
### 2.3 紧凑布局约束
170px 高度下必须严格控制间距,避免纵向滚动:
| 约束项 | 值 |
|--------|-----|
| Screen / Sidebar / Content `pad_all` | ≤ 2px |
| 导航按钮间距 | 2px |
| SettingsBody 行高 | 3236px |
| 字体 | Phase 1 使用 `LV_FONT_DEFAULT`Phase 3 可换 12px 定制字体 |
| 滚动 | Screen、Sidebar、Content 均移除 `LV_OBJ_FLAG_SCROLLABLE` |
### 2.4 System Tab 控件坐标(相对 SettingsBody
| 控件 | x | y | 宽 | 高 | 类型 |
|------|---|---|----|----|------|
| Label "Enable Notification" | 4 | 4 | 120 | 28 | `lv_label` |
| Switch 通知 | 180 | 4 | 40 | 28 | `lv_switch` |
| Button "Factory Reset" | 4 | 112 | 100 | 28 | `lv_button` |
### 2.5 Display Tab 控件坐标(相对 SettingsBody
| 控件 | x | y | 宽 | 高 | 类型 |
|------|---|---|----|----|------|
| Label "Brightness" | 4 | 4 | 80 | 28 | `lv_label` |
| Slider 亮度 | 88 | 8 | 100 | 12 | `lv_slider` |
| Label "70%" | 196 | 4 | 30 | 28 | `lv_label` |
> Audio / Network Tab 坐标见 §5.2;布局模式一致(label 左 80px + 控件区 140px)。
---
## 3. 系统架构
### 3.1 模块划分
```text
main/bsp/
├── board_config.h # 新增 BOARD_LCD_LOGIC_H_RES/V_RES
├── lvgl_port.c # rotation + flush_cb 软件旋转(真机验证)
├── touch.c # 物理面板 touch 对齐(非 LVGL 旋转)
└── display.c # 背光 API(已有)
main/ui/
├── screens/
│ └── ui_Settings.c/.h # 屏幕骨架:sidebar + content + Save
├── settings/
│ ├── settings_types.h # settings_data_t、Tab 枚举
│ ├── settings_store.c/.h # NVS 读写、默认值、commit/reset
│ └── settings_tabs.c/.h # load_xxx_settings() 动态构建控件
├── ui.c / ui.h # 注册 Settings 屏幕
└── ui_events.c # SquareLine 生成,不修改
main/app/
└── ui_handlers.c # Settings 事件回调(Save/Tab/Slider 等)
```
### 3.2 架构图
```mermaid
flowchart TB
subgraph bsp [BSP Layer]
Display["display.c"]
LVGLPort["lvgl_port.c"]
Touch["touch.c"]
end
subgraph ui [UI Layer]
SettingsScreen["ui_Settings.c"]
SettingsTabs["settings_tabs.c"]
SettingsStore["settings_store.c"]
end
subgraph app [App Layer]
UIHandlers["ui_handlers.c"]
end
Display --> LVGLPort
LVGLPort --> SettingsScreen
Touch --> LVGLPort
SettingsScreen --> SettingsTabs
SettingsScreen --> SettingsStore
SettingsTabs --> UIHandlers
UIHandlers --> SettingsStore
SettingsStore --> NVS["nvs_flash"]
SettingsStore --> Display
```
### 3.3 Tab 切换数据流
```mermaid
sequenceDiagram
participant User as 用户
participant Sidebar as Sidebar
participant Content as content_obj
participant Tabs as settings_tabs
participant Store as settings_store
User->>Sidebar: 点击 Display(当前在 System
Sidebar->>Tabs: settings_tabs_collect(SYSTEM, buf)
Tabs->>Store: settings_store_update(buf)
Sidebar->>Sidebar: 更新选中高亮
Sidebar->>Content: lv_obj_clean(content_obj)
Sidebar->>Tabs: load_display_settings(content, store)
Tabs->>Content: 创建 slider/label
Tabs->>Store: 读取 brightness_pct 填充控件
User->>Sidebar: 点击 Save
Sidebar->>Tabs: settings_tabs_collect(active_tab, buf)
Tabs->>Store: settings_store_update(buf)
Store->>Store: settings_store_commit()
Store->>Store: settings_store_apply()
```
**Tab 切换前必须先 collect 当前 Tab 到内存**,否则未 Save 的控件改动会在 `lv_obj_clean` 后丢失。Save 仅 collect **当前 active Tab**,但 `settings_store_commit()` 持久化 **内存 struct 全部字段**(含其它 Tab 切换前已 collect 的值)。
---
## 4. 全局横屏基础设施(BSP)
### 4.1 逻辑分辨率宏
`main/bsp/board_config.h` 新增:
```c
/* Physical panel pixels (portrait wiring) */
#define BOARD_LCD_H_RES 170
#define BOARD_LCD_V_RES 320
/* LVGL logical resolution after 90° rotation (landscape) */
#define BOARD_LCD_LOGIC_H_RES 320
#define BOARD_LCD_LOGIC_V_RES 170
```
UI 代码 **统一使用** `BOARD_LCD_LOGIC_H_RES` / `BOARD_LCD_LOGIC_V_RES` 作为屏幕尺寸。
### 4.2 Display 旋转与 flush_cb
`main/bsp/lvgl_port.c``lvgl_port_init()` 中,`lv_display_create` 之后添加:
```c
lv_display_t *display =
lv_display_create(BOARD_LCD_H_RES, BOARD_LCD_V_RES);
lv_display_set_rotation(display, LV_DISPLAY_ROTATION_90);
/* 逻辑分辨率:320×170 */
```
说明(依据 [LVGL 9.2 Display porting — Rotation](https://docs.lvgl.io/9.2/porting/display.html)):
- `lv_display_set_rotation()` **交换逻辑分辨率**320×170),并变换 **触摸坐标****不会自动旋转像素**
- 本项目无 ST7789 硬件 scan rotation,须走 **软件旋转** 路径
- 当前为 `LV_DISPLAY_RENDER_MODE_PARTIAL`:LVGL 可能在内部按块旋转后再调用 `flush_cb`,**但必须在真机验证**;若画面仍呈竖屏或区域错位,在 `flush_cb` 中补充:
```c
lv_display_rotate_area(disp, (lv_area_t *)&rotated_area);
lv_draw_sw_rotate(px_map, rotated_buf, src_w, src_h, src_stride, dst_stride,
lv_display_get_rotation(disp), cf);
/* 再将 rotated_buf / rotated_area 传给 esp_lcd_panel_draw_bitmap */
```
- 绘制 buffer 大小仍基于物理 `BOARD_LCD_H_RES × BOARD_LVGL_DRAW_BUF_LINES`;逻辑宽变为 320 后 partial 分块与软件旋转 **CPU 开销上升**,需关注刷新流畅度(见 §11
**Phase 1 步骤 1.2 验收:** 加载 Settings 页后,Sidebar 在左、Content 在右,文字可读,无镜像/错位。
### 4.3 Panel Gap 与 flush 坐标
物理面板为 240×320,有效显示区 170×320 居中,`display.c` 已通过 `esp_lcd_panel_set_gap(BOARD_LCD_GAP_X, 0)` 设置 **35px 水平偏移**`BOARD_LCD_GAP_X = (240170)/2`)。
旋转 90° 后 gap 方向可能变化;若 `flush_cb` 需手动 `lv_draw_sw_rotate`,须同步验证:
1. 画面是否整体偏移或裁切
2. `lv_display_rotate_area` 输出坐标加上 gap offset 是否正确
3. 四角触控是否与像素对齐
**Phase 1 步骤 1.2/1.3 一并验收**,必要时在 flush 路径对 area 做 gap 补偿。
### 4.4 Touch 坐标映射
**原则:触摸驱动只做「物理竖屏面板」对齐;LVGL 负责 rotation 后的逻辑坐标变换。**
- `esp_lcd_touch_config_t``x_max` / `y_max` **保持** 物理尺寸 170×320,不随逻辑横屏改变
- `lv_indev_set_display(indev, display)` 关联 display 后,LVGL 9 会根据 `lv_display_set_rotation()` **在内部变换触摸点**
- 因此 `swap_xy` / `mirror_x` / `mirror_y` **仅用于** 触摸 IC 原始坐标 → 物理面板(170×320、原点左上角)的对齐,**不得** 为了 90° 逻辑横屏在驱动层做 rotation 补偿,否则 **双重变换**
**初始值(与当前 `touch.c` 一致,待真机验证):**
```c
.flags = {
.swap_xy = 0,
.mirror_x = 0,
.mirror_y = 0,
},
```
**校准步骤:**
1. **先**`lv_display_set_rotation(90°)`**保持** touch flags 全 0,加载 Settings 页
2. 在四个逻辑角点击,观察触控是否落在对应 UI 区域
3. 若整体镜像或轴互换,**仅调整物理对齐 flags**swap × mirror 共 8 种),每次改一项并记录
4. 确认 Sidebar 左侧按钮可准确触控后,写入 `board_config.h`
```c
#define BOARD_TOUCH_SWAP_XY 0 /* 真机确认值 */
#define BOARD_TOUCH_MIRROR_X 0
#define BOARD_TOUCH_MIRROR_Y 0
```
> 注释须标明:**物理面板对齐,非 LVGL 逻辑旋转**。
### 4.5 现有 Screen 兼容
| 屏幕 | 现状 | 横屏影响 | 处理阶段 |
|------|------|----------|----------|
| Screen1 日历 | 170×320 居中 | 布局错位 | Phase 3 重排或替换 |
| Screen2 | SquareLine 生成 | 需验证 | Phase 3 |
| Screen3 HID 控制 | 绝对坐标按钮 | 需重排 | Phase 3 |
| **Settings** | 新建 | 按 320×170 设计 | **Phase 1** |
Phase 1 建议将 `ui.c``lv_disp_load_scr()` 默认屏改为 `ui_Settings`,便于验收横屏与 Settings 功能。
---
## 5. Settings UI 规格
### 5.1 屏幕骨架 API
**`main/ui/screens/ui_Settings.h`**
```c
#pragma once
#include "lvgl.h"
#include "settings_types.h"
extern lv_obj_t *ui_Settings;
extern lv_obj_t *ui_Settings_sidebar;
extern lv_obj_t *ui_Settings_content;
void ui_Settings_screen_init(void);
void ui_Settings_screen_destroy(void);
/* 切换 Tab 时由 ui_handlers 调用 */
void ui_Settings_switch_tab(settings_tab_t tab);
settings_tab_t ui_Settings_get_active_tab(void);
```
**初始化伪代码:**
```c
void ui_Settings_screen_init(void)
{
ui_Settings = lv_obj_create(NULL);
lv_obj_set_size(ui_Settings, BOARD_LCD_LOGIC_H_RES, BOARD_LCD_LOGIC_V_RES);
lv_obj_remove_flag(ui_Settings, LV_OBJ_FLAG_SCROLLABLE);
ui_Settings_sidebar = lv_list_create(ui_Settings);
lv_obj_set_size(ui_Settings_sidebar, 90, 170);
lv_obj_set_pos(ui_Settings_sidebar, 0, 0);
lv_obj_remove_flag(ui_Settings_sidebar, LV_OBJ_FLAG_SCROLLABLE);
/* 4 个导航 button + 事件 */
/* Save button 固定底部 */
ui_Settings_content = lv_obj_create(ui_Settings);
lv_obj_set_size(ui_Settings_content, 230, 170);
lv_obj_set_pos(ui_Settings_content, 90, 0);
lv_obj_remove_flag(ui_Settings_content, LV_OBJ_FLAG_SCROLLABLE);
settings_store_load();
settings_store_apply(); /* 启动时恢复 NVS 背光等到硬件 */
load_system_settings(ui_Settings_content, settings_store_get());
}
```
### 5.2 各 Tab MVP 内容
#### System Tab
| 控件 | LVGL 类型 | 数据字段 | 行为 |
|------|-----------|----------|------|
| Enable Notification | `lv_switch` | `notification_enabled` | `VALUE_CHANGED` 时更新内存;Tab 切换前 collectSave 时 commit |
| Factory Reset | `lv_button` | — | 确认后 `settings_store_factory_reset()` + `esp_restart()` |
StatusBar 标题:`"System Settings"`
#### Display Tab
| 控件 | LVGL 类型 | 数据字段 | 行为 |
|------|-----------|----------|------|
| Brightness | `lv_slider` (0100) + `%` label | `brightness_pct` | `VALUE_CHANGED` 时:`settings_store` 更新内存 + `settings_store_apply()` 实时调背光;Save 时 commit |
StatusBar 标题:`"Display Settings"`
#### Audio Tab
| 控件 | LVGL 类型 | 数据字段 | 行为 |
|------|-----------|----------|------|
| Volume | `lv_slider` (0100) | `volume_pct` | `VALUE_CHANGED` 更新内存;Phase 1 无 codec 输出 |
| Mute | `lv_switch` | `mute` | `VALUE_CHANGED` 更新内存;Phase 1 无 codec 输出 |
StatusBar 标题:`"Audio Settings"`
#### Network Tab
| 控件 | LVGL 类型 | 行为 |
|------|-----------|------|
| WiFi Status | `lv_label` | 显示 `"WiFi: Disabled"``BOARD_WIFI_ENABLED=0` |
| Scan | `lv_button` | Phase 1 点击显示 `"Not available"` 日志;Phase 4 接 `wifi_manager` |
StatusBar 标题:`"Network Settings"`
### 5.3 Tab 加载函数
**`main/ui/settings/settings_tabs.h`**
```c
#pragma once
#include "lvgl.h"
#include "settings_types.h"
void load_system_settings(lv_obj_t *content, const settings_data_t *data);
void load_display_settings(lv_obj_t *content, const settings_data_t *data);
void load_audio_settings(lv_obj_t *content, const settings_data_t *data);
void load_network_settings(lv_obj_t *content, const settings_data_t *data);
/* Save / Tab 切换前:从指定 Tab 控件回读到 outout 应先拷贝 settings_store_get() */
void settings_tabs_collect(settings_tab_t tab, settings_data_t *out);
```
每次切换 Tab**先 collect 再 clean**):
```c
void ui_Settings_switch_tab(settings_tab_t tab)
{
settings_data_t buf = *settings_store_get();
settings_tabs_collect(s_active_tab, &buf);
settings_store_update(&buf);
s_active_tab = tab;
sidebar_update_highlight(tab);
lv_obj_clean(ui_Settings_content);
switch (tab) {
case SETTINGS_TAB_SYSTEM:
load_system_settings(ui_Settings_content, settings_store_get());
break;
case SETTINGS_TAB_DISPLAY:
load_display_settings(ui_Settings_content, settings_store_get());
break;
case SETTINGS_TAB_AUDIO:
load_audio_settings(ui_Settings_content, settings_store_get());
break;
case SETTINGS_TAB_NETWORK:
load_network_settings(ui_Settings_content, settings_store_get());
break;
default:
LV_ASSERT_MSG(false, "unexpected settings_tab_t");
break;
}
}
```
### 5.4 样式规范
`ui_Settings.c` 内使用 static `lv_style_t`(或 `settings_style.c`):
| 元素 | 属性 | 值 |
|------|------|-----|
| Sidebar 按钮(默认) | 宽×高 | 86×28 |
| Sidebar 按钮(选中) | 背景色 | `lv_palette_main(LV_PALETTE_BLUE)` |
| Content 背景 | 背景色 | `lv_palette_lighten(LV_PALETTE_GREY, 2)` |
| StatusBar 分隔线 | 高度 1px | bottom border 或 `lv_obj` 线 |
| Slider | 宽 | 100120px |
| Factory Reset 按钮 | 文字色 | `lv_palette_main(LV_PALETTE_RED)` |
**Style 生命周期:** 使用文件级 static style,在 `ui_Settings_screen_init()` 初始化一次;`lv_obj_clean(content)` 仅删除子对象,不删除 static style,避免泄漏。
### 5.5 StatusBar 时间
| 阶段 | 行为 |
|------|------|
| Phase 1 | 静态占位 `"--:--"` |
| Phase 4 | SNTP 同步后 `lv_label_set_text_fmt()` 每分钟更新 |
---
## 6. 数据模型与 NVS
### 6.1 类型定义
**`main/ui/settings/settings_types.h`**
```c
#pragma once
#include <stdint.h>
#include <stdbool.h>
typedef enum {
SETTINGS_TAB_SYSTEM = 0,
SETTINGS_TAB_DISPLAY,
SETTINGS_TAB_AUDIO,
SETTINGS_TAB_NETWORK,
} settings_tab_t;
typedef struct {
bool notification_enabled;
uint8_t brightness_pct; /* 0100, default 70 */
uint8_t volume_pct; /* 0100, default 50 */
bool mute;
} settings_data_t;
```
### 6.2 默认值
| 字段 | 默认值 | 说明 |
|------|--------|------|
| `notification_enabled` | `false` | 与 mockup 一致,默认关闭 |
| `brightness_pct` | `70` | 与 mockup 一致 |
| `volume_pct` | `50` | 居中 |
| `mute` | `false` | — |
```c
static const settings_data_t SETTINGS_DEFAULTS = {
.notification_enabled = false,
.brightness_pct = 70,
.volume_pct = 50,
.mute = false,
};
```
### 6.3 NVS Schema
| 属性 | 值 |
|------|-----|
| Namespace | `"settings"` |
| 存储格式 | 各 key 独立 u8/blob,便于单项迁移 |
| Key | 类型 | 字段 | 说明 |
|-----|------|------|------|
| `"notif"` | `uint8_t` | `notification_enabled` | 0/1 |
| `"bright"` | `uint8_t` | `brightness_pct` | 0100 |
| `"vol"` | `uint8_t` | `volume_pct` | 0100 |
| `"mute"` | `uint8_t` | `mute` | 0/1 |
### 6.4 Store API
**`main/ui/settings/settings_store.h`**
```c
#pragma once
#include "settings_types.h"
void settings_store_load(void);
const settings_data_t *settings_store_get(void);
void settings_store_update(const settings_data_t *data);
esp_err_t settings_store_commit(void);
esp_err_t settings_store_factory_reset(void);
void settings_store_apply(void); /* 将内存值应用到硬件(背光等) */
```
| 函数 | 说明 |
|------|------|
| `settings_store_load` | 启动时从 NVS 读取;key 不存在则用 `SETTINGS_DEFAULTS` |
| `settings_store_get` | 返回内存中当前 struct 指针(只读) |
| `settings_store_update` | 用 collect 结果更新内存 structcollect 前应先 `*out = *settings_store_get()` |
| `settings_store_commit` | 将全部字段写入 NVS |
| `settings_store_factory_reset` | 擦除 `"settings"` namespace,恢复默认值,commit |
| `settings_store_apply` | 调用 `display_backlight_set_brightness()` 等即时生效项 |
### 6.5 Save 流程
```mermaid
sequenceDiagram
participant User
participant SaveBtn
participant Tabs as settings_tabs
participant Store as settings_store
participant NVS
participant HW as display.c
User->>SaveBtn: 点击 Save
SaveBtn->>Tabs: settings_tabs_collect(active_tab, buf)
Tabs->>Store: settings_store_update(buf)
Store->>NVS: settings_store_commit()
Store->>HW: settings_store_apply()
SaveBtn->>User: 可选:短暂 toast "Saved"
```
**注意:**
- Display Tab 亮度在 `VALUE_CHANGED` 时已通过 `settings_store_apply()` 实时调背光并更新内存;Save 负责写入 NVS
- 跨 Tab 修改:切换 Tab 时 collect 当前 Tab 到内存;最终 Save 一次 commit 全部字段
- 背光等硬件调用统一经 `settings_store_apply()`**不** 在 `settings_tabs.c` 直接调用 `display.c`(见 §7.1
### 6.6 Factory Reset 流程
1. 用户点击 Factory Reset 按钮
2. Phase 1 无确认对话框,直接执行(Phase 2 可选加 `lv_msgbox` 确认,见 §8
3. 调用 `settings_store_factory_reset()`:擦除 NVS namespace,恢复 `SETTINGS_DEFAULTS`
4. 调用 `settings_store_apply()` 恢复背光
5. `esp_restart()`
---
## 7. 事件与集成
### 7.1 事件归属
| 事件 | 注册位置 | 处理函数(`ui_handlers.c` |
|------|----------|----------------------------|
| Sidebar Tab 点击 | `ui_Settings.c` init | `settings_on_tab_clicked()` |
| Save 点击 | `ui_Settings.c` init | `settings_on_save_clicked()` |
| Factory Reset | `settings_tabs.c` 创建时 | `settings_on_factory_reset()` |
| Brightness slider | `settings_tabs.c` 创建时 | `settings_on_brightness_changed()` → 更新 store + `settings_store_apply()` |
| 其他 slider/switch | 各 load 函数 | `settings_on_value_changed()` → 更新 store 内存 |
**原则:** Settings 相关回调一律放在 `main/app/ui_handlers.c`**不修改** SquareLine 生成的 `ui_events.c`。硬件侧效应(背光)经 `settings_store_apply()`UI 层不直接 `#include "display.h"`
### 7.2 CMakeLists.txt 变更
`main/CMakeLists.txt``SRCS` 中添加:
```text
"ui/screens/ui_Settings.c"
"ui/settings/settings_store.c"
"ui/settings/settings_tabs.c"
```
### 7.3 ui.h / ui.c 变更
```c
/* ui.h */
#include "screens/ui_Settings.h"
/* ui_init() */
ui_Settings_screen_init();
/* Phase 1 默认屏(LVGL 9 亦可写 lv_screen_load(ui_Settings) */
lv_disp_load_scr(ui_Settings);
/* ui_destroy() */
ui_Settings_screen_destroy();
```
### 7.4 初始化顺序
`app_main.c` 中保持现有顺序,Settings 无额外要求:
```text
nvs_flash_init → display_init → lvgl_port_init → touch_init → ui_init → ui_handlers_init
```
`settings_store_load()``settings_store_apply()``ui_Settings_screen_init()` 内调用(NVS 已就绪),确保重启后背光等与 NVS 一致。
---
## 8. 实现步骤
### 阶段 0:规格审核
| 步骤 | 内容 | 状态 |
|------|------|------|
| 0.1 | 审核本文档 v1.2 | 完成 |
| 0.2 | 确认横屏全局策略与 Tab MVP 范围 | 待确认 |
### 阶段 1BSP 横屏 + Settings 全量 MVP
| 步骤 | 内容 |
|------|------|
| 1.1 | `board_config.h` 增加逻辑分辨率宏、touch 对齐宏 |
| 1.2 | `lvgl_port.c``lv_display_set_rotation(90°)`;真机验证 flush,必要时 `lv_draw_sw_rotate` + gap 补偿 |
| 1.3 | `touch.c`:物理面板对齐 flags 真机验证(勿与 LVGL rotation 双重变换) |
| 1.4 | 新建 `settings_types.h``settings_store.c`(含 `apply` |
| 1.5 | 新建 `ui_Settings.c`Sidebar + Content + Save |
| 1.6 | 新建 `settings_tabs.c`:四个 `load_xxx_settings()` + `settings_tabs_collect()` |
| 1.7 | `ui_handlers.c`Tab 切换(先 collect)、Save、Factory Reset、slider/switch 回调 |
| 1.8 | 更新 `CMakeLists.txt``ui.c``ui.h` |
| 1.9 | 默认加载 Settings 页,真机验收 §10 全部 AC |
**Phase 1 验收:** 与 §1.2 MVP 及 §10 验收标准 AC-1AC-8 一致(含四个 Tab、Display 亮度、跨 Tab Save、Factory Reset)。
### 阶段 2:体验增强(可选,非 MVP 阻塞)
| 步骤 | 内容 |
|------|------|
| 2.1 | Factory Reset `lv_msgbox` 确认对话框 |
| 2.2 | Save 成功 toast / 视觉反馈 |
| 2.3 | Sidebar / Tab 切换过渡动画 |
### 阶段 3:现有 Screen 横屏适配
| 步骤 | 内容 |
|------|------|
| 3.1 | Screen3 HID 控制页按 320×170 重排 |
| 3.2 | Screen1/2 适配或废弃 |
| 3.3 | 多 Screen 导航入口(如 Settings 返回主屏) |
### 阶段 4:增强功能(后续)
| 步骤 | 内容 |
|------|------|
| 4.1 | SNTP 时间显示 |
| 4.2 | 通知功能真实实现 |
| 4.3 | WiFi 扫描/连接 UI`BOARD_WIFI_ENABLED=1` |
| 4.4 | Audio codec 对接 |
| 4.5 | Factory Reset 确认对话框(若未在 Phase 2 完成) |
---
## 9. 与现有代码对应关系
| 现有 | Settings 实施后 |
|------|-----------------|
| `lv_display_create(170, 320)` | 不变物理尺寸 + `set_rotation(90°)`flush 可能需软件旋转 |
| `ui_Screen3` 默认启动 | Phase 1 改为 `ui_Settings`Phase 3 增加导航 |
| `ui_events.c` SquareLine 回调 | 不修改;Settings 事件放 `ui_handlers.c` |
| `display_backlight_set_brightness()` | 经 `settings_store_apply()` 调用,不在 UI Tab 层直接调用 |
| `nvs_flash` 已初始化 | Settings 使用独立 namespace `"settings"` |
| `wifi_manager.c` | Network Tab Phase 4 对接 |
| SquareLine Screen1/2/3 | Phase 3 适配,Settings 不纳入 SquareLine |
---
## 10. 验收标准
| 编号 | 验收项 | 标准 |
|------|--------|------|
| AC-1 | 逻辑分辨率 | LVGL 屏幕 320×170Sidebar 90px + Content 230px |
| AC-2 | 触摸 | 四角及 Sidebar 按钮触控准确,无误触 |
| AC-3 | Tab 切换 | 4 个 Tab 可切换,右侧内容正确刷新,无滚动条 |
| AC-4 | System Tab | Switch/Factory Reset 可交互 |
| AC-5 | Display Tab | 亮度 slider 实时改变背光 |
| AC-6 | Save | 跨 Tab 修改后 Save,重启后全部设置值从 NVS 恢复 |
| AC-7 | Factory Reset | 恢复默认值、背光恢复 70%、设备重启 |
| AC-8 | 内存 | Tab 反复切换 50 次无 crash、无显著内存增长 |
---
## 11. 风险与约束
1. **flush 软件旋转**`lv_display_set_rotation` 不保证自动旋转像素;须真机验证,必要时改 `flush_cb`(§4.2)。
2. **触摸校准**:区分物理对齐与 LVGL rotation;错误地在 touch 驱动层做 rotation 补偿会导致双重变换(§4.4)。
3. **Panel gap**:240 宽面板 170 有效区居中,旋转后须验证 offset(§4.3)。
4. **Partial 模式性能**:逻辑宽 320 + 软件旋转增加 CPU 负担,若卡顿可考虑增大 `BOARD_LVGL_DRAW_BUF_LINES` 或后续评估 DIRECT 模式。
5. **SquareLine 冲突**Settings 手写 C,不纳入 SquareLine 工程;重新导出 UI 时不应覆盖 `ui_Settings.c``ui_handlers.c` 中 Settings 部分。
6. **WiFi 默认关闭**Network Tab Phase 1 仅为占位,避免用户误解为已连网。
7. **170px 高度**:控件过多时需合并 Tab 或减小行高,禁止启用纵向滚动。
8. **Style 生命周期**Tab 切换使用 `lv_obj_clean` 而非 `lv_obj_del` 整个 contentstatic style 不可绑定到会被删除的对象上。
9. **跨 Tab 数据**Tab 切换前必须 collectslider/switch 的 `VALUE_CHANGED` 应同步内存,避免 UI 与硬件不一致。
10. **Factory Reset 范围**Phase 1 仅擦除 `"settings"` namespaceBLE bonding、快捷键等其它 NVS namespace(见 `ble-hid-keyboard.md` 规划)保留。
11. **Screen 兼容**Phase 1 默认 Settings 页会导致 Screen1/2/3 暂不可直接验收,属预期行为。
---
## 12. 参考
- 板级配置:`main/bsp/board_config.h`
- LVGL Port`main/bsp/lvgl_port.c`
- 背光 API`main/bsp/display.h`
- 现有 UI 入口:`main/ui/ui.c`
- 事件处理范例:`main/app/ui_handlers.c`
- 姊妹规格:`docs/spec/ble-hid-keyboard.md`
- LVGL 9.2 Display Rotation[porting/display.html — Rotation](https://docs.lvgl.io/9.2/porting/display.html)
- LVGL API`lv_display_set_rotation()``lv_display_rotate_area()``lv_draw_sw_rotate()`
---
## 修订记录
| 版本 | 日期 | 说明 |
|------|------|------|
| 1.0 | 2026-07-05 | 初版:Settings 横屏 320×170 布局、BSP、NVS、分阶段实现规格 |
| 1.1 | 2026-07-05 | 移除语言选择(dropdown / NVS lang / settings_language_t |
| 1.2 | 2026-07-05 | 技术修订:统一 Tab 线框与 MVP 范围;修正 LVGL 9 flush/touch 旋转说明;Tab 切换先 collect;启动 apply 背光;补 gap/分层/跨 Tab Save 约束 |
+6 -1
View File
@@ -14,6 +14,9 @@ idf_component_register(SRCS
"ui/screens/ui_Screen1.c" "ui/screens/ui_Screen1.c"
"ui/screens/ui_Screen2.c" "ui/screens/ui_Screen2.c"
"ui/screens/ui_Screen3.c" "ui/screens/ui_Screen3.c"
"ui/screens/ui_Settings.c"
"ui/settings/settings_store.c"
"ui/settings/settings_tabs.c"
"ui/widgets/ui_comp_hook.c" "ui/widgets/ui_comp_hook.c"
PRIV_REQUIRES PRIV_REQUIRES
bt bt
@@ -30,4 +33,6 @@ idf_component_register(SRCS
"app" "app"
"bsp" "bsp"
"services" "services"
"ui") "ui"
"ui/settings"
)
+82
View File
@@ -1,10 +1,15 @@
#include "ui_handlers.h" #include "ui_handlers.h"
#include <stdbool.h> #include <stdbool.h>
#include <stdint.h>
#include "ble_hid.h" #include "ble_hid.h"
#include "boot_button.h" #include "boot_button.h"
#include "esp_log.h" #include "esp_log.h"
#include "esp_system.h"
#include "screens/ui_Settings.h"
#include "settings/settings_store.h"
#include "settings/settings_tabs.h"
#include "ui_events.h" #include "ui_events.h"
static const char *TAG = "ui_handlers"; static const char *TAG = "ui_handlers";
@@ -37,6 +42,83 @@ void ui_handlers_init(void)
boot_button_init(on_boot_press); boot_button_init(on_boot_press);
} }
void settings_on_tab_clicked(lv_event_t *e)
{
settings_tab_t tab = (settings_tab_t)(intptr_t)lv_event_get_user_data(e);
ui_Settings_switch_tab(tab);
}
void settings_on_save_clicked(lv_event_t *e)
{
(void)e;
settings_data_t buf = *settings_store_get();
settings_tabs_collect(ui_Settings_get_active_tab(), &buf);
settings_store_update(&buf);
esp_err_t err = settings_store_commit();
if (err == ESP_OK) {
settings_store_apply();
ESP_LOGI(TAG, "Settings saved");
} else {
ESP_LOGW(TAG, "Settings save failed");
}
}
void settings_on_factory_reset(lv_event_t *e)
{
(void)e;
ESP_LOGW(TAG, "Factory reset");
settings_store_factory_reset();
settings_store_apply();
esp_restart();
}
void settings_on_brightness_changed(lv_event_t *e)
{
lv_obj_t *slider = lv_event_get_target(e);
uint8_t pct = (uint8_t)lv_slider_get_value(slider);
settings_data_t buf = *settings_store_get();
buf.brightness_pct = pct;
settings_store_update(&buf);
settings_store_apply();
lv_obj_t *pct_label = lv_obj_get_user_data(slider);
if (pct_label != NULL) {
lv_label_set_text_fmt(pct_label, "%u%%", pct);
}
}
void settings_on_value_changed(lv_event_t *e)
{
settings_field_t field =
(settings_field_t)(intptr_t)lv_event_get_user_data(e);
lv_obj_t *target = lv_event_get_target(e);
settings_data_t buf = *settings_store_get();
switch (field) {
case SETTINGS_FIELD_NOTIFICATION:
buf.notification_enabled = lv_obj_has_state(target, LV_STATE_CHECKED);
break;
case SETTINGS_FIELD_VOLUME:
buf.volume_pct = (uint8_t)lv_slider_get_value(target);
break;
case SETTINGS_FIELD_MUTE:
buf.mute = lv_obj_has_state(target, LV_STATE_CHECKED);
break;
default:
LV_ASSERT_MSG(false, "unexpected settings_field_t");
break;
}
settings_store_update(&buf);
}
void settings_on_network_scan_clicked(lv_event_t *e)
{
(void)e;
ESP_LOGI(TAG, "Not available");
}
void update_year_action(lv_event_t * e) void update_year_action(lv_event_t * e)
{ {
(void)e; (void)e;
+9
View File
@@ -1,4 +1,13 @@
#pragma once #pragma once
#include "lvgl.h"
/* UI event callbacks declared in ui_events.h are implemented in ui_handlers.c */ /* UI event callbacks declared in ui_events.h are implemented in ui_handlers.c */
void ui_handlers_init(void); void ui_handlers_init(void);
void settings_on_tab_clicked(lv_event_t *e);
void settings_on_save_clicked(lv_event_t *e);
void settings_on_factory_reset(lv_event_t *e);
void settings_on_brightness_changed(lv_event_t *e);
void settings_on_value_changed(lv_event_t *e);
void settings_on_network_scan_clicked(lv_event_t *e);
+9
View File
@@ -9,6 +9,15 @@
#define BOARD_LCD_PIXEL_CLOCK_HZ (20 * 1000 * 1000) #define BOARD_LCD_PIXEL_CLOCK_HZ (20 * 1000 * 1000)
#define BOARD_LCD_H_RES 170 #define BOARD_LCD_H_RES 170
#define BOARD_LCD_V_RES 320 #define BOARD_LCD_V_RES 320
/* LVGL logical resolution after 90° rotation (landscape) */
#define BOARD_LCD_LOGIC_H_RES 320
#define BOARD_LCD_LOGIC_V_RES 170
/* Touch IC → physical panel alignment (not LVGL logical rotation) */
#define BOARD_TOUCH_SWAP_XY 0
#define BOARD_TOUCH_MIRROR_X 0
#define BOARD_TOUCH_MIRROR_Y 0
#define BOARD_LCD_PANEL_H_RES 240 #define BOARD_LCD_PANEL_H_RES 240
#define BOARD_LCD_GAP_X ((BOARD_LCD_PANEL_H_RES - BOARD_LCD_H_RES) / 2) #define BOARD_LCD_GAP_X ((BOARD_LCD_PANEL_H_RES - BOARD_LCD_H_RES) / 2)
#define BOARD_LCD_GAP_Y 0 #define BOARD_LCD_GAP_Y 0
+34 -6
View File
@@ -2,6 +2,7 @@
#include "board_config.h" #include "board_config.h"
#include "draw/sw/lv_draw_sw.h"
#include "driver/spi_master.h" #include "driver/spi_master.h"
#include "esp_lcd_panel_ops.h" #include "esp_lcd_panel_ops.h"
#include "esp_log.h" #include "esp_log.h"
@@ -31,14 +32,40 @@ static bool notify_flush_ready(esp_lcd_panel_io_handle_t panel_io,
static void flush_cb(lv_display_t *disp, const lv_area_t *area, uint8_t *px_map) static void flush_cb(lv_display_t *disp, const lv_area_t *area, uint8_t *px_map)
{ {
esp_lcd_panel_handle_t panel_handle = lv_display_get_user_data(disp); esp_lcd_panel_handle_t panel_handle = lv_display_get_user_data(disp);
int offsetx1 = area->x1; const lv_area_t *draw_area = area;
int offsetx2 = area->x2; uint8_t *draw_buf = px_map;
int offsety1 = area->y1;
int offsety2 = area->y2; static uint8_t rotated_buf[BOARD_LCD_H_RES * BOARD_LVGL_DRAW_BUF_LINES *
lv_draw_sw_rgb565_swap(px_map, sizeof(lv_color16_t)];
lv_display_rotation_t rotation = lv_display_get_rotation(disp);
lv_area_t rotated_area;
if (rotation != LV_DISPLAY_ROTATION_0) {
lv_color_format_t cf = lv_display_get_color_format(disp);
rotated_area = *area;
lv_display_rotate_area(disp, &rotated_area);
int32_t src_w = lv_area_get_width(area);
int32_t src_h = lv_area_get_height(area);
uint32_t src_stride = lv_draw_buf_width_to_stride(src_w, cf);
uint32_t dest_stride =
lv_draw_buf_width_to_stride(lv_area_get_width(&rotated_area), cf);
lv_draw_sw_rotate(px_map, rotated_buf, src_w, src_h, src_stride,
dest_stride, rotation, cf);
draw_area = &rotated_area;
draw_buf = rotated_buf;
}
int offsetx1 = draw_area->x1;
int offsetx2 = draw_area->x2;
int offsety1 = draw_area->y1;
int offsety2 = draw_area->y2;
lv_draw_sw_rgb565_swap(draw_buf,
(offsetx2 + 1 - offsetx1) * (offsety2 + 1 - offsety1)); (offsetx2 + 1 - offsetx1) * (offsety2 + 1 - offsety1));
esp_lcd_panel_draw_bitmap(panel_handle, offsetx1, offsety1, offsetx2 + 1, esp_lcd_panel_draw_bitmap(panel_handle, offsetx1, offsety1, offsetx2 + 1,
offsety2 + 1, px_map); offsety2 + 1, draw_buf);
} }
static void increase_lvgl_tick(void *arg) static void increase_lvgl_tick(void *arg)
@@ -80,6 +107,7 @@ lv_display_t *lvgl_port_init(esp_lcd_panel_io_handle_t io_handle,
lv_display_t *display = lv_display_t *display =
lv_display_create(BOARD_LCD_H_RES, BOARD_LCD_V_RES); lv_display_create(BOARD_LCD_H_RES, BOARD_LCD_V_RES);
lv_display_set_rotation(display, LV_DISPLAY_ROTATION_270);
size_t draw_buffer_sz = BOARD_LCD_H_RES * BOARD_LVGL_DRAW_BUF_LINES * size_t draw_buffer_sz = BOARD_LCD_H_RES * BOARD_LVGL_DRAW_BUF_LINES *
sizeof(lv_color16_t); sizeof(lv_color16_t);
+3 -3
View File
@@ -86,9 +86,9 @@ esp_err_t touch_init(lv_display_t *display)
.interrupt = 0, .interrupt = 0,
}, },
.flags = { .flags = {
.swap_xy = 0, .swap_xy = BOARD_TOUCH_SWAP_XY,
.mirror_x = 0, .mirror_x = BOARD_TOUCH_MIRROR_X,
.mirror_y = 0, .mirror_y = BOARD_TOUCH_MIRROR_Y,
}, },
.interrupt_callback = touch_isr_cb, .interrupt_callback = touch_isr_cb,
}; };
+154
View File
@@ -0,0 +1,154 @@
#include "ui_Settings.h"
#include "board_config.h"
#include "settings/settings_store.h"
#include "settings/settings_tabs.h"
#include "ui_handlers.h"
#include <stddef.h>
lv_obj_t *ui_Settings;
lv_obj_t *ui_Settings_sidebar;
lv_obj_t *ui_Settings_content;
static settings_tab_t s_active_tab = SETTINGS_TAB_SYSTEM;
static lv_obj_t *s_nav_buttons[4];
static lv_style_t s_style_sidebar_btn_active;
static lv_style_t s_style_content_bg;
static bool s_styles_inited;
static const char *s_tab_labels[] = {
"System",
"Display",
"Audio",
"Network",
};
static void init_styles(void)
{
if (s_styles_inited) {
return;
}
lv_style_init(&s_style_sidebar_btn_active);
lv_style_set_bg_color(&s_style_sidebar_btn_active,
lv_palette_main(LV_PALETTE_BLUE));
lv_style_set_bg_opa(&s_style_sidebar_btn_active, LV_OPA_COVER);
lv_style_init(&s_style_content_bg);
lv_style_set_bg_color(&s_style_content_bg,
lv_palette_lighten(LV_PALETTE_GREY, 2));
lv_style_set_bg_opa(&s_style_content_bg, LV_OPA_COVER);
s_styles_inited = true;
}
static void sidebar_update_highlight(settings_tab_t tab)
{
for (size_t i = 0; i < 4; i++) {
if (s_nav_buttons[i] == NULL) {
continue;
}
if ((settings_tab_t)i == tab) {
lv_obj_add_style(s_nav_buttons[i], &s_style_sidebar_btn_active, 0);
} else {
lv_obj_remove_style(s_nav_buttons[i], &s_style_sidebar_btn_active, 0);
}
}
}
settings_tab_t ui_Settings_get_active_tab(void)
{
return s_active_tab;
}
void ui_Settings_switch_tab(settings_tab_t tab)
{
settings_data_t buf = *settings_store_get();
settings_tabs_collect(s_active_tab, &buf);
settings_store_update(&buf);
s_active_tab = tab;
sidebar_update_highlight(tab);
lv_obj_clean(ui_Settings_content);
switch (tab) {
case SETTINGS_TAB_SYSTEM:
load_system_settings(ui_Settings_content, settings_store_get());
break;
case SETTINGS_TAB_DISPLAY:
load_display_settings(ui_Settings_content, settings_store_get());
break;
case SETTINGS_TAB_AUDIO:
load_audio_settings(ui_Settings_content, settings_store_get());
break;
case SETTINGS_TAB_NETWORK:
load_network_settings(ui_Settings_content, settings_store_get());
break;
default:
LV_ASSERT_MSG(false, "unexpected settings_tab_t");
break;
}
}
void ui_Settings_screen_init(void)
{
init_styles();
ui_Settings = lv_obj_create(NULL);
lv_obj_set_size(ui_Settings, BOARD_LCD_LOGIC_H_RES, BOARD_LCD_LOGIC_V_RES);
lv_obj_set_style_pad_all(ui_Settings, 2, 0);
lv_obj_remove_flag(ui_Settings, LV_OBJ_FLAG_SCROLLABLE);
ui_Settings_sidebar = lv_list_create(ui_Settings);
lv_obj_set_size(ui_Settings_sidebar, 90, 170);
lv_obj_set_pos(ui_Settings_sidebar, 0, 0);
lv_obj_set_style_pad_all(ui_Settings_sidebar, 2, 0);
lv_obj_set_style_pad_row(ui_Settings_sidebar, 2, 0);
lv_obj_remove_flag(ui_Settings_sidebar, LV_OBJ_FLAG_SCROLLABLE);
for (size_t i = 0; i < 4; i++) {
s_nav_buttons[i] =
lv_list_add_button(ui_Settings_sidebar, NULL, s_tab_labels[i]);
lv_obj_set_size(s_nav_buttons[i], 86, 28);
lv_obj_add_event_cb(s_nav_buttons[i], settings_on_tab_clicked,
LV_EVENT_CLICKED, (void *)(intptr_t)i);
}
lv_obj_t *save_btn = lv_button_create(ui_Settings);
lv_obj_set_size(save_btn, 86, 28);
lv_obj_set_pos(save_btn, 2, 138);
lv_obj_t *save_label = lv_label_create(save_btn);
lv_label_set_text(save_label, "Save");
lv_obj_center(save_label);
lv_obj_add_event_cb(save_btn, settings_on_save_clicked, LV_EVENT_CLICKED,
NULL);
ui_Settings_content = lv_obj_create(ui_Settings);
lv_obj_set_size(ui_Settings_content, 230, 170);
lv_obj_set_pos(ui_Settings_content, 90, 0);
lv_obj_set_style_pad_all(ui_Settings_content, 2, 0);
lv_obj_add_style(ui_Settings_content, &s_style_content_bg, 0);
lv_obj_remove_flag(ui_Settings_content, LV_OBJ_FLAG_SCROLLABLE);
settings_store_load();
settings_store_apply();
s_active_tab = SETTINGS_TAB_SYSTEM;
sidebar_update_highlight(s_active_tab);
load_system_settings(ui_Settings_content, settings_store_get());
}
void ui_Settings_screen_destroy(void)
{
if (ui_Settings != NULL) {
lv_obj_del(ui_Settings);
ui_Settings = NULL;
ui_Settings_sidebar = NULL;
ui_Settings_content = NULL;
}
for (size_t i = 0; i < 4; i++) {
s_nav_buttons[i] = NULL;
}
}
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include "lvgl.h"
#include "settings_types.h"
extern lv_obj_t *ui_Settings;
extern lv_obj_t *ui_Settings_sidebar;
extern lv_obj_t *ui_Settings_content;
void ui_Settings_screen_init(void);
void ui_Settings_screen_destroy(void);
void ui_Settings_switch_tab(settings_tab_t tab);
settings_tab_t ui_Settings_get_active_tab(void);
+121
View File
@@ -0,0 +1,121 @@
#include "settings_store.h"
#include "display.h"
#include "esp_log.h"
#include "nvs.h"
#include "nvs_flash.h"
static const char *TAG = "settings_store";
static const char *SETTINGS_NAMESPACE = "settings";
static const settings_data_t SETTINGS_DEFAULTS = {
.notification_enabled = false,
.brightness_pct = 70,
.volume_pct = 50,
.mute = false,
};
static settings_data_t s_data;
static void load_key_u8(nvs_handle_t handle, const char *key, uint8_t *out,
uint8_t default_val)
{
uint8_t val = default_val;
if (nvs_get_u8(handle, key, &val) != ESP_OK) {
val = default_val;
}
*out = val;
}
void settings_store_load(void)
{
s_data = SETTINGS_DEFAULTS;
nvs_handle_t handle;
esp_err_t err = nvs_open(SETTINGS_NAMESPACE, NVS_READONLY, &handle);
if (err != ESP_OK) {
ESP_LOGI(TAG, "No saved settings, using defaults");
return;
}
uint8_t val;
load_key_u8(handle, "notif", &val, SETTINGS_DEFAULTS.notification_enabled);
s_data.notification_enabled = val != 0;
load_key_u8(handle, "bright", &s_data.brightness_pct,
SETTINGS_DEFAULTS.brightness_pct);
load_key_u8(handle, "vol", &s_data.volume_pct, SETTINGS_DEFAULTS.volume_pct);
load_key_u8(handle, "mute", &val, SETTINGS_DEFAULTS.mute);
s_data.mute = val != 0;
nvs_close(handle);
ESP_LOGI(TAG, "Settings loaded (bright=%u vol=%u)", s_data.brightness_pct,
s_data.volume_pct);
}
const settings_data_t *settings_store_get(void)
{
return &s_data;
}
void settings_store_update(const settings_data_t *data)
{
s_data = *data;
}
esp_err_t settings_store_commit(void)
{
nvs_handle_t handle;
esp_err_t err = nvs_open(SETTINGS_NAMESPACE, NVS_READWRITE, &handle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "nvs_open failed: %s", esp_err_to_name(err));
return err;
}
err = nvs_set_u8(handle, "notif", s_data.notification_enabled ? 1 : 0);
if (err == ESP_OK) {
err = nvs_set_u8(handle, "bright", s_data.brightness_pct);
}
if (err == ESP_OK) {
err = nvs_set_u8(handle, "vol", s_data.volume_pct);
}
if (err == ESP_OK) {
err = nvs_set_u8(handle, "mute", s_data.mute ? 1 : 0);
}
if (err == ESP_OK) {
err = nvs_commit(handle);
}
nvs_close(handle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "commit failed: %s", esp_err_to_name(err));
}
return err;
}
esp_err_t settings_store_factory_reset(void)
{
nvs_handle_t handle;
esp_err_t err = nvs_open(SETTINGS_NAMESPACE, NVS_READWRITE, &handle);
if (err == ESP_OK) {
err = nvs_erase_all(handle);
if (err == ESP_OK) {
err = nvs_commit(handle);
}
nvs_close(handle);
}
s_data = SETTINGS_DEFAULTS;
esp_err_t commit_err = settings_store_commit();
if (err != ESP_OK) {
return err;
}
return commit_err;
}
void settings_store_apply(void)
{
uint16_t duty = (uint16_t)((s_data.brightness_pct * 1023U) / 100U);
display_backlight_set_brightness(duty);
}
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include "esp_err.h"
#include "settings_types.h"
void settings_store_load(void);
const settings_data_t *settings_store_get(void);
void settings_store_update(const settings_data_t *data);
esp_err_t settings_store_commit(void);
esp_err_t settings_store_factory_reset(void);
void settings_store_apply(void);
+194
View File
@@ -0,0 +1,194 @@
#include "settings_tabs.h"
#include "ui_handlers.h"
static lv_obj_t *s_notif_switch;
static lv_obj_t *s_brightness_slider;
static lv_obj_t *s_brightness_pct_label;
static lv_obj_t *s_volume_slider;
static lv_obj_t *s_mute_switch;
static lv_obj_t *create_status_bar(lv_obj_t *content, const char *title)
{
lv_obj_t *status_bar = lv_obj_create(content);
lv_obj_set_size(status_bar, 230, 25);
lv_obj_set_pos(status_bar, 0, 0);
lv_obj_set_style_pad_all(status_bar, 2, 0);
lv_obj_set_style_border_width(status_bar, 1, 0);
lv_obj_set_style_border_side(status_bar, LV_BORDER_SIDE_BOTTOM, 0);
lv_obj_remove_flag(status_bar, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t *title_label = lv_label_create(status_bar);
lv_label_set_text(title_label, title);
lv_obj_align(title_label, LV_ALIGN_LEFT_MID, 2, 0);
lv_obj_t *time_label = lv_label_create(status_bar);
lv_label_set_text(time_label, "--:--");
lv_obj_align(time_label, LV_ALIGN_RIGHT_MID, -2, 0);
return status_bar;
}
static lv_obj_t *create_settings_body(lv_obj_t *content)
{
lv_obj_t *body = lv_obj_create(content);
lv_obj_set_size(body, 230, 145);
lv_obj_set_pos(body, 0, 25);
lv_obj_set_style_pad_all(body, 2, 0);
lv_obj_set_style_border_width(body, 0, 0);
lv_obj_remove_flag(body, LV_OBJ_FLAG_SCROLLABLE);
return body;
}
void load_system_settings(lv_obj_t *content, const settings_data_t *data)
{
s_notif_switch = NULL;
create_status_bar(content, "System Settings");
lv_obj_t *body = create_settings_body(content);
lv_obj_t *notif_label = lv_label_create(body);
lv_label_set_text(notif_label, "Enable Notification");
lv_obj_set_pos(notif_label, 4, 4);
lv_obj_set_size(notif_label, 120, 28);
s_notif_switch = lv_switch_create(body);
lv_obj_set_pos(s_notif_switch, 180, 4);
lv_obj_set_size(s_notif_switch, 40, 28);
if (data->notification_enabled) {
lv_obj_add_state(s_notif_switch, LV_STATE_CHECKED);
}
lv_obj_add_event_cb(s_notif_switch, settings_on_value_changed,
LV_EVENT_VALUE_CHANGED,
(void *)(intptr_t)SETTINGS_FIELD_NOTIFICATION);
lv_obj_t *reset_btn = lv_button_create(body);
lv_obj_set_pos(reset_btn, 4, 112);
lv_obj_set_size(reset_btn, 100, 28);
lv_obj_set_style_text_color(reset_btn, lv_palette_main(LV_PALETTE_RED), 0);
lv_obj_t *reset_label = lv_label_create(reset_btn);
lv_label_set_text(reset_label, "Factory Reset");
lv_obj_center(reset_label);
lv_obj_add_event_cb(reset_btn, settings_on_factory_reset, LV_EVENT_CLICKED,
NULL);
}
void load_display_settings(lv_obj_t *content, const settings_data_t *data)
{
s_brightness_slider = NULL;
s_brightness_pct_label = NULL;
create_status_bar(content, "Display Settings");
lv_obj_t *body = create_settings_body(content);
lv_obj_t *bright_label = lv_label_create(body);
lv_label_set_text(bright_label, "Brightness");
lv_obj_set_pos(bright_label, 4, 4);
lv_obj_set_size(bright_label, 80, 28);
s_brightness_slider = lv_slider_create(body);
lv_obj_set_pos(s_brightness_slider, 88, 8);
lv_obj_set_size(s_brightness_slider, 100, 12);
lv_slider_set_range(s_brightness_slider, 0, 100);
lv_slider_set_value(s_brightness_slider, data->brightness_pct, LV_ANIM_OFF);
s_brightness_pct_label = lv_label_create(body);
lv_label_set_text_fmt(s_brightness_pct_label, "%u%%", data->brightness_pct);
lv_obj_set_pos(s_brightness_pct_label, 196, 4);
lv_obj_set_size(s_brightness_pct_label, 30, 28);
lv_obj_set_user_data(s_brightness_slider, s_brightness_pct_label);
lv_obj_add_event_cb(s_brightness_slider, settings_on_brightness_changed,
LV_EVENT_VALUE_CHANGED, NULL);
}
void load_audio_settings(lv_obj_t *content, const settings_data_t *data)
{
s_volume_slider = NULL;
s_mute_switch = NULL;
create_status_bar(content, "Audio Settings");
lv_obj_t *body = create_settings_body(content);
lv_obj_t *vol_label = lv_label_create(body);
lv_label_set_text(vol_label, "Volume");
lv_obj_set_pos(vol_label, 4, 4);
lv_obj_set_size(vol_label, 80, 28);
s_volume_slider = lv_slider_create(body);
lv_obj_set_pos(s_volume_slider, 88, 8);
lv_obj_set_size(s_volume_slider, 100, 12);
lv_slider_set_range(s_volume_slider, 0, 100);
lv_slider_set_value(s_volume_slider, data->volume_pct, LV_ANIM_OFF);
lv_obj_add_event_cb(s_volume_slider, settings_on_value_changed,
LV_EVENT_VALUE_CHANGED,
(void *)(intptr_t)SETTINGS_FIELD_VOLUME);
lv_obj_t *mute_label = lv_label_create(body);
lv_label_set_text(mute_label, "Mute");
lv_obj_set_pos(mute_label, 4, 40);
lv_obj_set_size(mute_label, 80, 28);
s_mute_switch = lv_switch_create(body);
lv_obj_set_pos(s_mute_switch, 180, 40);
lv_obj_set_size(s_mute_switch, 40, 28);
if (data->mute) {
lv_obj_add_state(s_mute_switch, LV_STATE_CHECKED);
}
lv_obj_add_event_cb(s_mute_switch, settings_on_value_changed,
LV_EVENT_VALUE_CHANGED,
(void *)(intptr_t)SETTINGS_FIELD_MUTE);
}
void load_network_settings(lv_obj_t *content, const settings_data_t *data)
{
(void)data;
create_status_bar(content, "Network Settings");
lv_obj_t *body = create_settings_body(content);
lv_obj_t *wifi_label = lv_label_create(body);
lv_label_set_text(wifi_label, "WiFi: Disabled");
lv_obj_set_pos(wifi_label, 4, 4);
lv_obj_set_size(wifi_label, 140, 28);
lv_obj_t *scan_btn = lv_button_create(body);
lv_obj_set_pos(scan_btn, 4, 40);
lv_obj_set_size(scan_btn, 80, 28);
lv_obj_t *scan_label = lv_label_create(scan_btn);
lv_label_set_text(scan_label, "Scan");
lv_obj_center(scan_label);
lv_obj_add_event_cb(scan_btn, settings_on_network_scan_clicked,
LV_EVENT_CLICKED, NULL);
}
void settings_tabs_collect(settings_tab_t tab, settings_data_t *out)
{
switch (tab) {
case SETTINGS_TAB_SYSTEM:
if (s_notif_switch != NULL) {
out->notification_enabled =
lv_obj_has_state(s_notif_switch, LV_STATE_CHECKED);
}
break;
case SETTINGS_TAB_DISPLAY:
if (s_brightness_slider != NULL) {
out->brightness_pct =
(uint8_t)lv_slider_get_value(s_brightness_slider);
}
break;
case SETTINGS_TAB_AUDIO:
if (s_volume_slider != NULL) {
out->volume_pct = (uint8_t)lv_slider_get_value(s_volume_slider);
}
if (s_mute_switch != NULL) {
out->mute = lv_obj_has_state(s_mute_switch, LV_STATE_CHECKED);
}
break;
case SETTINGS_TAB_NETWORK:
break;
default:
LV_ASSERT_MSG(false, "unexpected settings_tab_t");
break;
}
}
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include "lvgl.h"
#include "settings_types.h"
typedef enum {
SETTINGS_FIELD_NOTIFICATION = 0,
SETTINGS_FIELD_VOLUME,
SETTINGS_FIELD_MUTE,
} settings_field_t;
void load_system_settings(lv_obj_t *content, const settings_data_t *data);
void load_display_settings(lv_obj_t *content, const settings_data_t *data);
void load_audio_settings(lv_obj_t *content, const settings_data_t *data);
void load_network_settings(lv_obj_t *content, const settings_data_t *data);
void settings_tabs_collect(settings_tab_t tab, settings_data_t *out);
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
typedef enum {
SETTINGS_TAB_SYSTEM = 0,
SETTINGS_TAB_DISPLAY,
SETTINGS_TAB_AUDIO,
SETTINGS_TAB_NETWORK,
} settings_tab_t;
typedef struct {
bool notification_enabled;
uint8_t brightness_pct;
uint8_t volume_pct;
bool mute;
} settings_data_t;
+3 -1
View File
@@ -33,8 +33,9 @@ void ui_init(void)
ui_Screen1_screen_init(); ui_Screen1_screen_init();
ui_Screen2_screen_init(); ui_Screen2_screen_init();
ui_Screen3_screen_init(); ui_Screen3_screen_init();
ui_Settings_screen_init();
ui____initial_actions0 = lv_obj_create(NULL); ui____initial_actions0 = lv_obj_create(NULL);
lv_disp_load_scr(ui_Screen3); lv_disp_load_scr(ui_Settings);
} }
void ui_destroy(void) void ui_destroy(void)
@@ -42,4 +43,5 @@ void ui_destroy(void)
ui_Screen1_screen_destroy(); ui_Screen1_screen_destroy();
ui_Screen2_screen_destroy(); ui_Screen2_screen_destroy();
ui_Screen3_screen_destroy(); ui_Screen3_screen_destroy();
ui_Settings_screen_destroy();
} }
+1
View File
@@ -21,6 +21,7 @@ extern "C" {
#include "screens/ui_Screen1.h" #include "screens/ui_Screen1.h"
#include "screens/ui_Screen2.h" #include "screens/ui_Screen2.h"
#include "screens/ui_Screen3.h" #include "screens/ui_Screen3.h"
#include "screens/ui_Settings.h"
///////////////////// VARIABLES //////////////////// ///////////////////// VARIABLES ////////////////////