Files
cardboy/Firmware/sdk/apps/lockscreen/src/lockscreen_app.cpp

593 lines
25 KiB
C++

#include "cardboy/apps/lockscreen_app.hpp"
#include "cardboy/apps/menu_app.hpp"
#include "cardboy/gfx/font16x8.hpp"
#include "cardboy/sdk/app_framework.hpp"
#include "cardboy/sdk/app_system.hpp"
#include <algorithm>
#include <array>
#include <cstdio>
#include <ctime>
#include <string>
#include <string_view>
#include <vector>
namespace apps {
namespace {
using cardboy::sdk::AppButtonEvent;
using cardboy::sdk::AppContext;
using cardboy::sdk::AppTimeoutEvent;
using cardboy::sdk::AppTimerEvent;
constexpr std::uint32_t kRefreshIntervalMs = 100;
constexpr std::uint32_t kSlowRefreshMs = 1000;
constexpr std::uint32_t kFastRefreshMs = 20;
constexpr std::uint32_t kUnlockHoldMs = 1500;
using Framebuffer = typename AppContext::Framebuffer;
using Clock = typename AppContext::Clock;
constexpr std::array<std::uint8_t, font16x8::kGlyphHeight> kArrowUpGlyph{
0b00010000, 0b00111000, 0b01111100, 0b11111110, 0b00010000, 0b00010000, 0b00010000, 0b00010000,
0b00010000, 0b00010000, 0b00010000, 0b00010000, 0b00010000, 0b00010000, 0b00000000, 0b00000000};
constexpr std::array<std::uint8_t, font16x8::kGlyphHeight> kArrowDownGlyph{
0b00000000, 0b00000000, 0b00010000, 0b00010000, 0b00010000, 0b00010000, 0b00010000, 0b00010000,
0b00010000, 0b00010000, 0b00010000, 0b11111110, 0b01111100, 0b00111000, 0b00010000, 0b00000000};
struct TimeSnapshot {
bool hasWallTime = false;
int hour24 = 0;
int minute = 0;
int second = 0;
int year = 0;
int month = 0;
int day = 0;
int weekday = 0;
std::uint64_t uptimeSeconds = 0;
};
class LockscreenApp final : public cardboy::sdk::IApp {
public:
explicit LockscreenApp(AppContext& ctx) :
context(ctx), framebuffer(ctx.framebuffer), clock(ctx.clock), notificationCenter(ctx.notificationCenter()) {}
void onStart() override {
cancelRefreshTimer();
lastSnapshot = {};
holdActive = false;
holdProgressMs = 0;
dirty = true;
lastNotificationInteractionMs = clock.millis();
lastRefreshMs = clock.millis();
refreshNotifications();
const auto snap = captureTime();
renderIfNeeded(snap);
lastSnapshot = snap;
rescheduleRefreshTimer(kSlowRefreshMs);
}
void onStop() override { cancelRefreshTimer(); }
std::optional<std::uint32_t> handleEvent(const cardboy::sdk::AppEvent& event) override {
event.visit(cardboy::sdk::overload([this](const AppButtonEvent& button) { handleButtonEvent(button); },
[this](const AppTimerEvent& timer) {
if (timer.handle == refreshTimer) {
const std::uint32_t now = clock.millis();
const std::uint32_t elapsed = now - lastRefreshMs;
lastRefreshMs = now;
advanceHoldProgress(elapsed);
updateDisplay();
}
},
[](const AppTimeoutEvent&) { /* ignore */ }));
return std::nullopt;
}
private:
static constexpr std::size_t kMaxDisplayedNotifications = 5;
static constexpr std::uint32_t kNotificationHideMs = 8000;
AppContext& context;
Framebuffer& framebuffer;
Clock& clock;
cardboy::sdk::INotificationCenter* notificationCenter = nullptr;
std::uint32_t lastNotificationRevision = 0;
std::vector<cardboy::sdk::INotificationCenter::Notification> notifications;
std::size_t selectedNotification = 0;
bool dirty = false;
cardboy::sdk::AppTimerHandle refreshTimer = cardboy::sdk::kInvalidAppTimer;
TimeSnapshot lastSnapshot{};
bool holdActive = false;
std::uint32_t holdProgressMs = 0;
std::uint32_t lastNotificationInteractionMs = 0;
std::uint32_t lastRefreshMs = 0;
void cancelRefreshTimer() {
if (refreshTimer == cardboy::sdk::kInvalidAppTimer)
return;
if (auto* timer = context.timer())
timer->cancelTimer(refreshTimer);
refreshTimer = cardboy::sdk::kInvalidAppTimer;
}
void rescheduleRefreshTimer(std::uint32_t intervalMs) {
cancelRefreshTimer();
if (auto* timer = context.timer())
refreshTimer = timer->scheduleTimer(intervalMs, true);
}
static bool comboPressed(const cardboy::sdk::InputState& state) { return state.a && state.select; }
void handleButtonEvent(const cardboy::sdk::AppButtonEvent& button) {
const bool upPressed = button.current.up && !button.previous.up;
const bool downPressed = button.current.down && !button.previous.down;
bool navPressed = false;
if (!notifications.empty() && (upPressed || downPressed)) {
const std::size_t count = notifications.size();
lastNotificationInteractionMs = clock.millis();
if (count > 1) {
if (upPressed && selectedNotification > 0) {
selectedNotification--;
navPressed = true;
} else if (downPressed && selectedNotification < count - 1) {
selectedNotification++;
navPressed = true;
}
}
}
const bool deletePressed = button.current.b && !button.previous.b;
if (deletePressed && notificationCenter && !notifications.empty()) {
const std::size_t index = std::min<std::size_t>(selectedNotification, notifications.size() - 1);
const auto& note = notifications[index];
std::size_t preferredIndex = index;
if (index + 1 < notifications.size())
preferredIndex = index + 1;
else if (index > 0)
preferredIndex = index - 1;
if (note.externalId != 0)
notificationCenter->removeByExternalId(note.externalId);
else
notificationCenter->removeById(note.id);
selectedNotification = preferredIndex;
lastNotificationInteractionMs = clock.millis();
dirty = true;
refreshNotifications();
}
const bool comboNow = comboPressed(button.current);
updateHoldState(comboNow);
if (navPressed)
dirty = true;
updateDisplay();
}
void refreshNotifications() {
if (!notificationCenter) {
if (!notifications.empty() || lastNotificationRevision != 0) {
notifications.clear();
selectedNotification = 0;
lastNotificationRevision = 0;
dirty = true;
}
return;
}
const std::uint32_t revision = notificationCenter->revision();
if (revision == lastNotificationRevision)
return;
lastNotificationRevision = revision;
const std::uint64_t previousId =
(selectedNotification < notifications.size()) ? notifications[selectedNotification].id : 0;
auto latest = notificationCenter->recent(kMaxDisplayedNotifications);
notifications = std::move(latest);
if (notifications.empty()) {
selectedNotification = 0;
} else if (previousId != 0) {
auto it = std::find_if(notifications.begin(), notifications.end(),
[previousId](const auto& note) { return note.id == previousId; });
if (it != notifications.end()) {
selectedNotification = static_cast<std::size_t>(std::distance(notifications.begin(), it));
} else {
selectedNotification = 0;
}
} else {
selectedNotification = 0;
}
lastNotificationInteractionMs = clock.millis();
dirty = true;
}
void updateHoldState(bool comboNow) {
const bool wasActive = holdActive;
if (comboNow) {
if (!holdActive) {
holdActive = true;
holdProgressMs = 0;
lastRefreshMs = clock.millis();
dirty = true;
}
} else {
if (holdActive || holdProgressMs != 0) {
holdActive = false;
holdProgressMs = 0;
dirty = true;
}
}
if (wasActive != holdActive) {
rescheduleRefreshTimer(holdActive ? kFastRefreshMs : kSlowRefreshMs);
}
}
void advanceHoldProgress(std::uint32_t elapsedMs) {
if (holdActive) {
const std::uint32_t next = std::min<std::uint32_t>(holdProgressMs + elapsedMs, kUnlockHoldMs);
if (next != holdProgressMs) {
holdProgressMs = next;
dirty = true;
}
if (holdProgressMs >= kUnlockHoldMs) {
holdActive = false;
context.requestAppSwitchByName(kMenuAppName);
}
} else if (holdProgressMs != 0) {
holdProgressMs = 0;
dirty = true;
}
}
void updateDisplay() {
refreshNotifications();
const auto snap = captureTime();
if (!sameSnapshot(snap, lastSnapshot))
dirty = true;
renderIfNeeded(snap);
lastSnapshot = snap;
}
static bool sameSnapshot(const TimeSnapshot& a, const TimeSnapshot& b) {
return a.hasWallTime == b.hasWallTime && a.hour24 == b.hour24 && a.minute == b.minute && a.second == b.second &&
a.day == b.day && a.month == b.month && a.year == b.year;
}
TimeSnapshot captureTime() const {
TimeSnapshot snap{};
snap.uptimeSeconds = clock.millis() / 1000ULL;
std::time_t raw = 0;
if (std::time(&raw) != static_cast<std::time_t>(-1) && raw > 0) {
std::tm tm{};
if (localtime_r(&raw, &tm) != nullptr) {
snap.hasWallTime = true;
snap.hour24 = tm.tm_hour;
snap.minute = tm.tm_min;
snap.second = tm.tm_sec;
snap.year = tm.tm_year + 1900;
snap.month = tm.tm_mon + 1;
snap.day = tm.tm_mday;
snap.weekday = tm.tm_wday;
return snap;
}
}
snap.hasWallTime = false;
snap.hour24 = static_cast<int>((snap.uptimeSeconds / 3600ULL) % 24ULL);
snap.minute = static_cast<int>((snap.uptimeSeconds / 60ULL) % 60ULL);
snap.second = static_cast<int>(snap.uptimeSeconds % 60ULL);
return snap;
}
static void drawCenteredText(Framebuffer& fb, int y, std::string_view text, int scale, int letterSpacing = 0) {
const int width = font16x8::measureText(text, scale, letterSpacing);
const int x = (fb.width() - width) / 2;
font16x8::drawText(fb, x, y, text, scale, true, letterSpacing);
}
static void drawRectOutline(Framebuffer& fb, int x, int y, int w, int h) {
if (w <= 0 || h <= 0)
return;
for (int dx = 0; dx < w; ++dx) {
fb.drawPixel(x + dx, y, true);
fb.drawPixel(x + dx, y + h - 1, true);
}
for (int dy = 0; dy < h; ++dy) {
fb.drawPixel(x, y + dy, true);
fb.drawPixel(x + w - 1, y + dy, true);
}
}
static void fillRect(Framebuffer& fb, int x, int y, int w, int h) {
if (w <= 0 || h <= 0)
return;
for (int dy = 0; dy < h; ++dy) {
for (int dx = 0; dx < w; ++dx) {
fb.drawPixel(x + dx, y + dy, true);
}
}
}
static void drawArrowGlyph(Framebuffer& fb, int x, int y,
const std::array<std::uint8_t, font16x8::kGlyphHeight>& glyph, int scale = 1) {
if (scale <= 0)
return;
for (int row = 0; row < font16x8::kGlyphHeight; ++row) {
const std::uint8_t rowBits = glyph[row];
for (int col = 0; col < font16x8::kGlyphWidth; ++col) {
const auto mask = static_cast<std::uint8_t>(1u << (font16x8::kGlyphWidth - 1 - col));
if ((rowBits & mask) == 0)
continue;
for (int sx = 0; sx < scale; ++sx) {
for (int sy = 0; sy < scale; ++sy) {
fb.drawPixel(x + col * scale + sx, y + row * scale + sy, true);
}
}
}
}
}
static void drawArrow(Framebuffer& fb, int x, int y, bool up, int scale = 1) {
const auto& glyph = up ? kArrowUpGlyph : kArrowDownGlyph;
drawArrowGlyph(fb, x, y, glyph, scale);
}
static std::string truncateWithEllipsis(std::string_view text, int maxWidth, int scale, int letterSpacing) {
if (font16x8::measureText(text, scale, letterSpacing) <= maxWidth)
return std::string(text);
std::string result(text.begin(), text.end());
const std::string ellipsis = "...";
while (!result.empty()) {
result.pop_back();
std::string candidate = result + ellipsis;
if (font16x8::measureText(candidate, scale, letterSpacing) <= maxWidth)
return candidate;
}
return ellipsis;
}
static std::vector<std::string> wrapText(std::string_view text, int maxWidth, int scale, int letterSpacing,
int maxLines) {
std::vector<std::string> lines;
if (text.empty() || maxWidth <= 0 || maxLines <= 0)
return lines;
std::string current;
std::string word;
bool truncated = false;
auto flushCurrent = [&]() {
if (current.empty())
return;
if (lines.size() < static_cast<std::size_t>(maxLines)) {
lines.push_back(current);
} else {
truncated = true;
}
current.clear();
};
for (std::size_t i = 0; i <= text.size(); ++i) {
char ch = (i < text.size()) ? text[i] : ' ';
const bool isBreak = (ch == ' ' || ch == '\n' || ch == '\r' || i == text.size());
if (!isBreak) {
word.push_back(ch);
continue;
}
if (!word.empty()) {
std::string candidate = current.empty() ? word : current + " " + word;
if (!current.empty() && font16x8::measureText(candidate, scale, letterSpacing) > maxWidth) {
flushCurrent();
if (lines.size() >= static_cast<std::size_t>(maxLines)) {
truncated = true;
break;
}
candidate = word;
}
if (font16x8::measureText(candidate, scale, letterSpacing) > maxWidth) {
std::string shortened = truncateWithEllipsis(word, maxWidth, scale, letterSpacing);
flushCurrent();
if (lines.size() < static_cast<std::size_t>(maxLines)) {
lines.push_back(shortened);
} else {
truncated = true;
break;
}
current.clear();
} else {
current = candidate;
}
word.clear();
}
if (ch == '\n' || ch == '\r') {
flushCurrent();
if (lines.size() >= static_cast<std::size_t>(maxLines)) {
truncated = true;
break;
}
}
}
flushCurrent();
if (lines.size() > static_cast<std::size_t>(maxLines)) {
truncated = true;
lines.resize(maxLines);
}
if (truncated && !lines.empty()) {
lines.back() = truncateWithEllipsis(lines.back(), maxWidth, scale, letterSpacing);
}
return lines;
}
static std::string formatDate(const TimeSnapshot& snap) {
static const char* kWeekdays[] = {"SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"};
if (!snap.hasWallTime)
return "UPTIME MODE";
const char* weekday = (snap.weekday >= 0 && snap.weekday < 7) ? kWeekdays[snap.weekday] : "";
char buffer[32];
std::snprintf(buffer, sizeof(buffer), "%s %04d-%02d-%02d", weekday, snap.year, snap.month, snap.day);
return buffer;
}
void renderIfNeeded(const TimeSnapshot& snap) {
if (!dirty)
return;
dirty = false;
framebuffer.frameReady();
const int scaleTime = 4;
const int scaleSeconds = 2;
const int scaleSmall = 1;
const int textLineHeight = font16x8::kGlyphHeight * scaleSmall;
const int cardMarginTop = 4;
const int cardMarginSide = 8;
const int cardPadding = 6;
const int cardLineSpacing = 4;
int cardHeight = 0;
const int cardWidth = framebuffer.width() - cardMarginSide * 2;
const std::uint32_t nowMs = clock.millis();
const bool hasNotifications = !notifications.empty();
const bool showNotificationDetails =
hasNotifications && (nowMs - lastNotificationInteractionMs <= kNotificationHideMs);
if (hasNotifications) {
const auto& note = notifications[selectedNotification];
if (showNotificationDetails) {
std::string title = note.title.empty() ? std::string("Notification") : note.title;
title = truncateWithEllipsis(title, cardWidth - cardPadding * 2, scaleSmall, 1);
auto bodyLines = wrapText(note.body, cardWidth - cardPadding * 2, scaleSmall, 1, 4);
cardHeight = cardPadding * 2 + textLineHeight;
if (!bodyLines.empty()) {
cardHeight += cardLineSpacing;
cardHeight += static_cast<int>(bodyLines.size()) * textLineHeight;
if (bodyLines.size() > 1)
cardHeight += (static_cast<int>(bodyLines.size()) - 1) * cardLineSpacing;
}
if (notifications.size() > 1) {
cardHeight = std::max(cardHeight, cardPadding * 2 + textLineHeight * 2 + cardLineSpacing + 8);
}
drawRectOutline(framebuffer, cardMarginSide, cardMarginTop, cardWidth, cardHeight);
if (cardWidth > 2 && cardHeight > 2)
drawRectOutline(framebuffer, cardMarginSide + 1, cardMarginTop + 1, cardWidth - 2, cardHeight - 2);
font16x8::drawText(framebuffer, cardMarginSide + cardPadding, cardMarginTop + cardPadding, title,
scaleSmall, true, 1);
if (notifications.size() > 1) {
char counter[16];
std::snprintf(counter, sizeof(counter), "%zu/%zu", selectedNotification + 1, notifications.size());
const int counterWidth = font16x8::measureText(counter, scaleSmall, 1);
const int counterX = cardMarginSide + cardWidth - cardPadding - counterWidth;
font16x8::drawText(framebuffer, counterX, cardMarginTop + cardPadding, counter, scaleSmall, true,
1);
const int arrowWidth = font16x8::kGlyphWidth * scaleSmall;
const int arrowSpacing = std::max(1, scaleSmall);
const int arrowsTotalWide = arrowWidth * 2 + arrowSpacing;
const int arrowX = counterX + (counterWidth - arrowsTotalWide) / 2;
const int arrowY = cardMarginTop + cardPadding + textLineHeight + 1;
if (selectedNotification > 0)
drawArrow(framebuffer, arrowX, arrowY, true, scaleSmall);
if (selectedNotification < notifications.size() - 1)
drawArrow(framebuffer, arrowX + arrowWidth + arrowSpacing, arrowY, false, scaleSmall);
const int arrowHeight = font16x8::kGlyphHeight * scaleSmall;
cardHeight = std::max(cardHeight, arrowY + arrowHeight - cardMarginTop);
}
if (!bodyLines.empty()) {
int bodyY = cardMarginTop + cardPadding + textLineHeight + cardLineSpacing;
for (const auto& line: bodyLines) {
font16x8::drawText(framebuffer, cardMarginSide + cardPadding, bodyY, line, scaleSmall, true, 1);
bodyY += textLineHeight + cardLineSpacing;
}
}
} else {
cardHeight = textLineHeight + cardPadding * 2;
char summary[32];
if (notifications.size() == 1)
std::snprintf(summary, sizeof(summary), "1 NOTIFICATION");
else
std::snprintf(summary, sizeof(summary), "%zu NOTIFICATIONS", notifications.size());
const int summaryWidth = font16x8::measureText(summary, scaleSmall, 1);
const int summaryX = (framebuffer.width() - summaryWidth) / 2;
const int summaryY = cardMarginTop;
font16x8::drawText(framebuffer, summaryX, summaryY, summary, scaleSmall, true, 1);
}
}
const int defaultTimeY = (framebuffer.height() - font16x8::kGlyphHeight * scaleTime) / 2 - 8;
const int minTimeY = (cardHeight > 0) ? (cardMarginTop + cardHeight + 12) : 16;
const int maxTimeY = std::max(minTimeY, framebuffer.height() - font16x8::kGlyphHeight * scaleTime - 48);
const int timeY = std::clamp(defaultTimeY, minTimeY, maxTimeY);
char hoursMinutes[6];
std::snprintf(hoursMinutes, sizeof(hoursMinutes), "%02d:%02d", snap.hour24, snap.minute);
const int mainW = font16x8::measureText(hoursMinutes, scaleTime, 0);
const int timeX = (framebuffer.width() - mainW) / 2;
const int secX = timeX + mainW + 12;
const int secY = timeY + font16x8::kGlyphHeight * scaleTime - font16x8::kGlyphHeight * scaleSeconds;
char secs[3];
std::snprintf(secs, sizeof(secs), "%02d", snap.second);
font16x8::drawText(framebuffer, timeX, timeY, hoursMinutes, scaleTime, true, 0);
font16x8::drawText(framebuffer, secX, secY, secs, scaleSeconds, true, 0);
const std::string dateLine = formatDate(snap);
drawCenteredText(framebuffer, timeY + font16x8::kGlyphHeight * scaleTime + 16, dateLine, scaleSmall, 1);
const std::string instruction = "HOLD A+SELECT TO UNLOCK";
const int instructionWidth = font16x8::measureText(instruction, scaleSmall, 1);
const int barHeight = 18;
const int barY = framebuffer.height() - 30;
const int textY = barY + (barHeight - textLineHeight) / 2 + 1;
drawCenteredText(framebuffer, textY, instruction, scaleSmall, 1);
const int barWidth = framebuffer.width() - 64;
const int barX = 32;
if (holdActive || holdProgressMs > 0) {
drawRectOutline(framebuffer, barX, barY, barWidth, barHeight);
const int innerWidth = barWidth - 2;
const int innerHeight = barHeight - 2;
const float ratio = std::clamp(holdProgressMs / static_cast<float>(kUnlockHoldMs), 0.0f, 1.0f);
const int fillWidth = static_cast<int>(ratio * innerWidth + 0.5f);
if (fillWidth > 0)
fillRect(framebuffer, barX + 1, barY + 1, fillWidth, innerHeight);
}
framebuffer.sendFrame();
}
};
class LockscreenAppFactory final : public cardboy::sdk::IAppFactory {
public:
const char* name() const override { return kLockscreenAppName; }
std::unique_ptr<cardboy::sdk::IApp> create(cardboy::sdk::AppContext& context) override {
return std::make_unique<LockscreenApp>(context);
}
};
} // namespace
std::unique_ptr<cardboy::sdk::IAppFactory> createLockscreenAppFactory() {
return std::make_unique<LockscreenAppFactory>();
}
} // namespace apps