From 9db8287328b42812fc76ff63bb08f8b6cdb2121d Mon Sep 17 00:00:00 2001 From: Pavel Machek <8401486+pavelmachek@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:14:58 +0100 Subject: [PATCH] Add simple calendar application (#38) * cal: initial version * cal: hacks to get more functionality working * cal: Disable file output for now * cal: Got button mapping to work * cal: tweak power and size * cal: Tweak layouts * cal: got events to display * cal: single day addition now works * cal: got file i/o to work * cal: Layout tweaks * cal: Tweak add dialog * calendar/columns: start separate apps for them * calendar: open keyboard * calendar: make keyboard fit on small screen * calendar: better metadata for calendar * calendar: more metadata tweaks * calendar: revert hello tweaks * calendar: revert columns changes. * calendar: remove manifest from columns * calendar: attempt to fix MANIFEST --- .../META-INF/MANIFEST.JSON | 24 + .../apps/cz.ucw.pavel.calendar/assets/main.py | 560 ++++++++++++++++++ .../res/mipmap-mdpi/icon_64x64.png | Bin 0 -> 7294 bytes 3 files changed, 584 insertions(+) create mode 100644 internal_filesystem/apps/cz.ucw.pavel.calendar/META-INF/MANIFEST.JSON create mode 100644 internal_filesystem/apps/cz.ucw.pavel.calendar/assets/main.py create mode 100644 internal_filesystem/apps/cz.ucw.pavel.calendar/res/mipmap-mdpi/icon_64x64.png diff --git a/internal_filesystem/apps/cz.ucw.pavel.calendar/META-INF/MANIFEST.JSON b/internal_filesystem/apps/cz.ucw.pavel.calendar/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..b2f079cc --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.calendar/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "Calendar", +"publisher": "micropythonos", +"short_description": "Calendar", +"long_description": "Simple calendar app.", +"icon_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.columns/icons/cz.ucw.pavel.columns_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.columns/mpks/cz.ucw.pavel.columns_0.0.1.mpk", +"fullname": "cz.ucw.pavel.columns", +"version": "0.0.1", +"category": "utilities", +"activities": [ + { + "entrypoint": "assets/main.py", + "classname": "Main", + "intent_filters": [ + { + "action": "main", + "category": "utilities" + } + ] + } + ] +} + diff --git a/internal_filesystem/apps/cz.ucw.pavel.calendar/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.calendar/assets/main.py new file mode 100644 index 00000000..54708487 --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.calendar/assets/main.py @@ -0,0 +1,560 @@ +from mpos import Activity + +""" + +Create simple calendar application. On main screen, it should have +current time, date, and month overview. Current date and dates with +events should be highlighted. There should be list of upcoming events. + +When date is clicked, dialog with adding event for that date should be +displayed. Multi-day events should be supported. + +Data should be read/written to emacs org compatible text file. + + +""" + +import time +import os + +try: + import lvgl as lv +except ImportError: + pass + +from mpos import Activity, MposKeyboard + + +ORG_FILE = f"data/calendar.org" # adjust for your device +MAX_UPCOMING = 8 + + +# ------------------------------------------------------------ +# Small date helpers (no datetime module assumed) +# ------------------------------------------------------------ + +def is_leap_year(y): + return (y % 4 == 0 and y % 100 != 0) or (y % 400 == 0) + + +def days_in_month(y, m): + if m == 2: + return 29 if is_leap_year(y) else 28 + if m in (1, 3, 5, 7, 8, 10, 12): + return 31 + return 30 + + +def ymd_to_int(y, m, d): + return y * 10000 + m * 100 + d + + +def int_to_ymd(v): + y = v // 10000 + m = (v // 100) % 100 + d = v % 100 + return y, m, d + + +def weekday_name(idx): + # MicroPython localtime(): 0=Mon..6=Sun typically + names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + if 0 <= idx < 7: + return names[idx] + return "???" + + +def first_weekday_of_month(y, m): + # brute-force using time.mktime if available + # Some ports support it, some don't. + # If it fails, we fallback to "Monday". + try: + # localtime tuple: (y,m,d,h,mi,s,wd,yd) + t = time.mktime((y, m, 1, 0, 0, 0, 0, 0)) + wd = time.localtime(t)[6] + return wd + except Exception: + return 0 + + +# ------------------------------------------------------------ +# Org event model + parser/writer +# ------------------------------------------------------------ + +class Event: + def __init__(self, title, start_ymd, end_ymd, start_time=None, end_time=None): + self.title = title + self.start = start_ymd # int yyyymmdd + self.end = end_ymd # int yyyymmdd + self.start_time = start_time # "HH:MM" or None + self.end_time = end_time # "HH:MM" or None + + def is_multi_day(self): + return self.end != self.start + + def occurs_on(self, ymd): + return self.start <= ymd <= self.end + + def start_key(self): + return self.start + + +class OrgCalendarStore: + def __init__(self, path): + self.path = path + + def load(self): + if not self._exists(self.path): + return [] + + try: + with open(self.path, "r", encoding="utf-8") as f: + lines = f.read().splitlines() + except Exception: + # fallback without encoding kw if unsupported + with open(self.path, "r") as f: + lines = f.read().splitlines() + + events = [] + current_title = None + # FIXME this likely does not work + + for line in lines: + line = line.strip() + + if line.startswith("** "): + current_title = line[3:].strip() + continue + + if not line.startswith("<"): + continue + + ev = self._parse_timestamp_line(current_title, line) + if ev: + events.append(ev) + + events.sort(key=lambda e: e.start_key()) + return events + + def save_append(self, event): + # Create file if missing + if not self._exists(self.path): + self._write_text("* Events\n") + + # Append event + out = [] + out.append("** " + event.title) + + if event.start == event.end: + y, m, d = int_to_ymd(event.start) + wd = weekday_name(self._weekday_for_ymd(y, m, d)) + if event.start_time and event.end_time: + out.append("<%04d-%02d-%02d %s %s-%s>" % ( + y, m, d, wd, event.start_time, event.end_time + )) + else: + out.append("<%04d-%02d-%02d %s>" % (y, m, d, wd)) + else: + y1, m1, d1 = int_to_ymd(event.start) + y2, m2, d2 = int_to_ymd(event.end) + wd1 = weekday_name(self._weekday_for_ymd(y1, m1, d1)) + wd2 = weekday_name(self._weekday_for_ymd(y2, m2, d2)) + out.append("<%04d-%02d-%02d %s>--<%04d-%02d-%02d %s>" % ( + y1, m1, d1, wd1, + y2, m2, d2, wd2 + )) + + out.append("") # blank line + self._append_text("\n".join(out) + "\n") + + # -------------------- + + def _parse_timestamp_line(self, title, line): + if not title: + return None + + # Single-day: <2026-02-05 Thu> + # With time: <2026-02-05 Thu 10:00-11:00> + # Range: <2026-02-10 Tue>--<2026-02-14 Sat> + + if "--<" in line: + a, b = line.split("--", 1) + s = self._parse_one_timestamp(a) + e = self._parse_one_timestamp(b) + if not s or not e: + return None + return Event(title, s["ymd"], e["ymd"], None, None) + + s = self._parse_one_timestamp(line) + if not s: + return None + + return Event(title, s["ymd"], s["ymd"], s.get("start_time"), s.get("end_time")) + + def _parse_one_timestamp(self, token): + token = token.strip() + if not (token.startswith("<") and token.endswith(">")): + return None + + inner = token[1:-1].strip() + parts = inner.split() + + # Expect YYYY-MM-DD ... + if len(parts) < 2: + return None + + date_s = parts[0] + try: + y = int(date_s[0:4]) + m = int(date_s[5:7]) + d = int(date_s[8:10]) + except Exception: + return None + + ymd = ymd_to_int(y, m, d) + + # Optional time part like 10:00-11:00 + start_time = None + end_time = None + if len(parts) >= 3 and "-" in parts[2]: + t = parts[2] + if len(t) == 11 and t[2] == ":" and t[5] == "-" and t[8] == ":": + start_time = t[0:5] + end_time = t[6:11] + + return { + "ymd": ymd, + "start_time": start_time, + "end_time": end_time + } + + def _exists(self, path): + try: + os.stat(path) + return True + except Exception: + return False + + def _append_text(self, s): + with open(self.path, "a") as f: + f.write(s) + + def _write_text(self, s): + with open(self.path, "w") as f: + f.write(s) + + def _weekday_for_ymd(self, y, m, d): + try: + t = time.mktime((y, m, d, 0, 0, 0, 0, 0)) + return time.localtime(t)[6] + except Exception: + return 0 + + +# ------------------------------------------------------------ +# Calendar Activity +# ------------------------------------------------------------ + +class Main(Activity): + + def __init__(self): + super().__init__() + + self.store = OrgCalendarStore(ORG_FILE) + self.events = [] + + self.timer = None + + # UI + self.screen = None + self.lbl_time = None + self.lbl_date = None + self.lbl_month = None + + self.grid = None + self.day_buttons = [] + + self.upcoming_list = None + + # Current month shown + self.cur_y = 0 + self.cur_m = 0 + self.today_ymd = 0 + + # -------------------- + + def onCreate(self): + self.screen = lv.obj() + #self.screen.remove_flag(lv.obj.FLAG.SCROLLABLE) + + # Top labels + self.lbl_time = lv.label(self.screen) + self.lbl_time.set_style_text_font(lv.font_montserrat_20, 0) + self.lbl_time.align(lv.ALIGN.TOP_LEFT, 6, 4) + + self.lbl_date = lv.label(self.screen) + self.lbl_date.align(lv.ALIGN.TOP_LEFT, 6, 40) + + self.lbl_month = lv.label(self.screen) + self.lbl_month.align(lv.ALIGN.TOP_RIGHT, -6, 10) + + # Upcoming events list + self.upcoming_list = lv.list(self.screen) + self.upcoming_list.set_size(lv.pct(90), 60) + self.upcoming_list.align_to(self.lbl_date, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 10) + + # Month grid container + self.grid = lv.obj(self.screen) + self.grid.set_size(lv.pct(90), 60) + self.grid.set_style_border_width(1, 0) + self.grid.set_style_pad_all(0, 0) + self.grid.set_style_radius(6, 0) + self.grid.align_to(self.upcoming_list, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 10) + + self.setContentView(self.screen) + + self.reload_data() + print("My events == ", self.events) + self.build_month_view() + self.refresh_upcoming() + + def onResume(self, screen): + self.timer = lv.timer_create(self.tick, 30000, None) + self.tick(0) + + def onPause(self, screen): + if self.timer: + self.timer.delete() + self.timer = None + + # -------------------- + + def reload_data(self): + print("Loading...") + self.events = self.store.load() + # FIXME + #self.events = [ Event("Test event", 20260207, 20260208) ] + + def tick(self, t): + now = time.localtime() + y, m, d = now[0], now[1], now[2] + hh, mm, ss = now[3], now[4], now[5] + wd = weekday_name(now[6]) + + self.today_ymd = ymd_to_int(y, m, d) + + self.lbl_time.set_text("%02d:%02d" % (hh, mm)) + self.lbl_date.set_text("%04d-%02d-%02d %s" % (y, m, d, wd)) + + # Month label + self.lbl_month.set_text("%04d-%02d" % (self.cur_y, self.cur_m)) + + # Re-highlight today (cheap) + self.update_day_highlights() + + # -------------------- + + def build_month_view(self): + now = time.localtime() + self.cur_y, self.cur_m = now[0], now[1] + + # Determine size + d = lv.display_get_default() + w = d.get_horizontal_resolution() + + cell = w // 8 + grid_w = cell * 7 + 8 + grid_h = cell * 6 + 8 + + self.grid.set_size(grid_w, grid_h) + + # Clear old buttons + for b in self.day_buttons: + b.delete() + self.day_buttons = [] + self.day_of_btn = {} + + first_wd = first_weekday_of_month(self.cur_y, self.cur_m) # 0=Mon + dim = days_in_month(self.cur_y, self.cur_m) + + # LVGL grid is easiest as absolute positioning here + for day in range(1, dim + 1): + idx = (first_wd + (day - 1)) + row = idx // 7 + col = idx % 7 + + btn = lv.button(self.grid) + btn.set_size(cell - 2, cell - 2) + btn.set_pos(4 + col * cell, 4 + row * cell) + btn.add_event_cb(lambda e, dd=day: self.on_day_clicked(dd), lv.EVENT.CLICKED, None) + + lbl = lv.label(btn) + lbl.set_text(str(day)) + lbl.center() + + self.day_buttons.append(btn) + self.day_of_btn[btn] = day + + self.update_day_highlights() + + def update_day_highlights(self): + for btn in self.day_buttons: + day = self.day_of_btn.get(btn, None) + if day is None: + continue + + ymd = ymd_to_int(self.cur_y, self.cur_m, day) + + has_event = self.day_has_event(ymd) + is_today = (ymd == self.today_ymd) + #print(ymd, has_event, is_today) + + if is_today: + btn.set_style_bg_color(lv.palette_main(lv.PALETTE.BLUE), 0) + elif has_event: + btn.set_style_bg_color(lv.palette_main(lv.PALETTE.GREEN), 0) + else: + btn.set_style_bg_color(lv.palette_main(lv.PALETTE.GREY), 0) + + def day_has_event(self, ymd): + for e in self.events: + if e.occurs_on(ymd): + return True + return False + + # -------------------- + + def refresh_upcoming(self): + self.upcoming_list.clean() + + now = time.localtime() + today = ymd_to_int(now[0], now[1], now[2]) + + upcoming = [] + for e in self.events: + if e.end >= today: + upcoming.append(e) + + upcoming.sort(key=lambda e: e.start) + + for e in upcoming[:MAX_UPCOMING]: + y1, m1, d1 = int_to_ymd(e.start) + y2, m2, d2 = int_to_ymd(e.end) + + if e.start == e.end: + date_s = "%04d-%02d-%02d" % (y1, m1, d1) + else: + date_s = "%04d-%02d-%02d..%04d-%02d-%02d" % (y1, m1, d1, y2, m2, d2) + + txt = date_s + " " + e.title + self.upcoming_list.add_text(txt) + + self.upcoming_list.add_text("that's all folks") + + # -------------------- + + def on_day_clicked(self, day): + print("Day clicked") + ymd = ymd_to_int(self.cur_y, self.cur_m, day) + self.open_add_dialog(ymd) + + def open_add_dialog(self, ymd): + y, m, d = int_to_ymd(ymd) + + dlg = lv.obj(self.screen) + dlg.set_size(lv.pct(100), 480) + dlg.center() + dlg.set_style_bg_color(lv.color_hex(0x8f8f8f), 0) + dlg.set_style_border_width(2, 0) + dlg.set_style_radius(10, 0) + + title = lv.label(dlg) + title.set_text("Add event") + title.align(lv.ALIGN.TOP_MID, 0, 8) + + date_lbl = lv.label(dlg) + date_lbl.set_text("%04d-%02d-%02d" % (y, m, d)) + date_lbl.align_to(title, lv.ALIGN.OUT_BOTTOM_MID, 0, 10) + + # Title input + ti = lv.textarea(dlg) + ti.set_size(220, 32) + ti.align_to(date_lbl, lv.ALIGN.OUT_BOTTOM_MID, 0, 10) + ti.set_placeholder_text("Title") + keyboard = MposKeyboard(dlg) + keyboard.set_textarea(ti) + #keyboard.add_flag(lv.obj.FLAG.HIDDEN) + + # End date offset (days) + end_lbl = lv.label(dlg) + end_lbl.set_text("Duration days:") + end_lbl.align_to(ti, lv.ALIGN.OUT_BOTTOM_MID, 0, 10) + + dd = lv.dropdown(dlg) + dd.set_options("1\n2\n3\n4\n5\n6\n7\n10\n14\n21\n30") + dd.set_selected(0) + dd.set_size(70, 32) + dd.align_to(end_lbl, lv.ALIGN.OUT_BOTTOM_MID, 0, 10) + + # Buttons + btn_cancel = lv.button(dlg) + btn_cancel.set_size(90, 30) + btn_cancel.align(lv.ALIGN.TOP_LEFT, 12, 10) + btn_cancel.add_event_cb(lambda e: dlg.delete(), lv.EVENT.CLICKED, None) + lc = lv.label(btn_cancel) + lc.set_text("Cancel") + lc.center() + + btn_add = lv.button(dlg) + btn_add.set_size(90, 30) + btn_add.align(lv.ALIGN.TOP_RIGHT, -12, 10) + + def do_add(e): + title_s = ti.get_text() + if not title_s or title_s.strip() == "": + return + + dur_s = 1 # dd.get_selected_str() FIXME + try: + dur = int(dur_s) + except Exception: + dur = 1 + + end_ymd = self.add_days(ymd, dur - 1) + + ev = Event(title_s.strip(), ymd, end_ymd, None, None) + self.events.append(ev) + self.store.save_append(ev) # FIXME + + # Reload + refresh UI + # FIXME: common code? + #self.reload_data() + self.update_day_highlights() + self.refresh_upcoming() + + dlg.delete() + + btn_add.add_event_cb(do_add, lv.EVENT.CLICKED, None) + la = lv.label(btn_add) + la.set_text("Add") + la.center() + + # -------------------- + + def add_days(self, ymd, days): + # simple date add (forward only), no datetime dependency + y, m, d = int_to_ymd(ymd) + + while days > 0: + d += 1 + dim = days_in_month(y, m) + if d > dim: + d = 1 + m += 1 + if m > 12: + m = 1 + y += 1 + days -= 1 + + return ymd_to_int(y, m, d) + diff --git a/internal_filesystem/apps/cz.ucw.pavel.calendar/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/cz.ucw.pavel.calendar/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 0000000000000000000000000000000000000000..ee72f838043fae284c9d0836dd11e5b23d7a1bc1 GIT binary patch literal 7294 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 eP<_K$?+