Widget system Phase 4a: widget CRUD + grid placement UI
Adds the actual "Android home screen" placement experience: a new Layout tab with a pointer-driven canvas for dragging/resizing widgets and adding new ones from a type picker. Backed by a new routers/api_widgets.py (create/move/delete), which re-validates bounds, minimum footprint, and no-overlap server-side regardless of what the client already checked. A widget added without an explicit position lands in the first open space that fits it (grid.find_open_rect), so users don't have to hunt for empty space themselves. Also fixes a real latent bug this surfaced: changing a frame's orientation swaps the widget grid's long/short axis, which left existing widget placements out of bounds on the new grid with no render-time safeguard. Orientation changes now reset the layout to a single full-panel widget (keeping the first widget's type, dropping the rest), with a client-side confirm before it happens.
This commit is contained in:
@@ -25,7 +25,7 @@ from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import calendar_render, gitea_releases, photo_queue, quiet_hours, weather, webdav_client
|
||||
from .. import calendar_render, gitea_releases, grid, photo_queue, quiet_hours, weather, webdav_client
|
||||
from ..auth import require_frame_control, require_frame_view, require_user_api
|
||||
from ..db import frame_locked, get_db, widget_locked
|
||||
from ..image_pipeline import (
|
||||
@@ -36,7 +36,15 @@ from ..image_pipeline import (
|
||||
render_preview_png,
|
||||
)
|
||||
from ..firmware import firmware_path, parse_app_version
|
||||
from ..models import BatteryLog, CalendarWidgetConfig, Frame, FrameCalendar, PhotoWidgetConfig, WhiteboardWidgetConfig
|
||||
from ..models import (
|
||||
BatteryLog,
|
||||
CalendarWidgetConfig,
|
||||
Frame,
|
||||
FrameCalendar,
|
||||
PhotoWidgetConfig,
|
||||
WhiteboardWidgetConfig,
|
||||
Widget,
|
||||
)
|
||||
from .common import (
|
||||
OVERDUE_FACTOR,
|
||||
battery_estimate_s,
|
||||
@@ -67,6 +75,32 @@ MAX_QUEUE_TARGET_LEN = 5000
|
||||
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
|
||||
|
||||
|
||||
def _reset_widget_layout_for_new_orientation(db: Session, frame_id: int, new_orientation: str) -> None:
|
||||
"""A widget's x/y/w/h are grid cells relative to the OLD orientation's
|
||||
cols x rows (see grid.grid_dims) -- landscape and portrait use a
|
||||
transposed grid (8x5 vs 5x8), so an existing placement is often
|
||||
literally out of bounds on the new grid, not just visually wrong.
|
||||
There's no sensible coordinate remap between two differently-shaped
|
||||
grids, so instead: keep whichever widget was first by placement
|
||||
order, resized to fill the new full panel, and delete the rest --
|
||||
cascading to their own config rows and any FrameButtonAction
|
||||
bindings via ondelete="CASCADE" (see models.py). The frontend is
|
||||
expected to confirm this with the user before submitting an
|
||||
orientation change (see frame_config.js) -- this always executes
|
||||
unconditionally once called, same posture as every other
|
||||
confirm-on-the-client / act-unconditionally-on-the-server action in
|
||||
this codebase."""
|
||||
widgets = db.scalars(
|
||||
select(Widget).where(Widget.frame_id == frame_id).order_by(Widget.sort_order)
|
||||
).all()
|
||||
if not widgets:
|
||||
return
|
||||
keep, *rest = widgets
|
||||
for widget in rest:
|
||||
db.delete(widget)
|
||||
keep.x, keep.y, keep.w, keep.h = grid.full_panel_rect(new_orientation)
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/albums")
|
||||
def api_albums(frame: Frame = Depends(require_frame_view)):
|
||||
url, key = immich_creds(frame)
|
||||
@@ -124,6 +158,12 @@ def api_config_save(
|
||||
harmless no-ops if an old cached page still POSTs them -- FastAPI
|
||||
silently ignores form fields with no matching parameter.
|
||||
|
||||
An actual orientation *change* resets the frame's widget layout (see
|
||||
_reset_widget_layout_for_new_orientation) -- widget placement is
|
||||
grid-cell-relative to the panel's long/short axis, which swaps on a
|
||||
landscape<->portrait change, so an old placement is usually not just
|
||||
visually wrong but literally out of bounds on the new grid.
|
||||
|
||||
Until the widget-placement UI (a later phase) lets a frame have more
|
||||
than one widget of a type, "the photo widget" / "the calendar
|
||||
widget" below unambiguously means the frame's single auto-migrated
|
||||
@@ -138,7 +178,10 @@ def api_config_save(
|
||||
MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s)
|
||||
)
|
||||
if orientation is not None:
|
||||
cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape"
|
||||
new_orientation = orientation if orientation in ORIENTATIONS else "landscape"
|
||||
if new_orientation != cfg.orientation:
|
||||
_reset_widget_layout_for_new_orientation(db, cfg.id, new_orientation)
|
||||
cfg.orientation = new_orientation
|
||||
if quiet_hours_enabled is not None:
|
||||
cfg.quiet_hours_enabled = quiet_hours_enabled
|
||||
if quiet_hours_start is not None and quiet_hours.valid_hhmm(quiet_hours_start):
|
||||
|
||||
Reference in New Issue
Block a user