screen_home_ui <- function(id) { ns <- NS(id) shiny::tagList( shiny::uiOutput(ns("filter_row")), shiny::div(class = "mb-3", shiny::checkboxInput(ns("show_closed"), "Erledigte anzeigen", value = FALSE) ), shiny::uiOutput(ns("list_cards")), shiny::br(), shiny::actionButton(ns("new_btn"), shiny::tagList(shiny::icon("plus"), " Neue Liste"), class = "btn btn-primary w-100", style = "min-height:50px" ) ) } screen_home_server <- function(id, db) { moduleServer(id, function(input, output, session) { ns <- session$ns reload <- reactiveVal(0) type_filter <- reactiveVal("") expanded <- reactiveVal(integer(0)) # ---- Filter chips -------------------------------------------------------- FILTERS <- list( list(lbl = "Alle", val = ""), list(lbl = "Einkaufen", val = "shopping"), list(lbl = "Aufgaben", val = "todo"), list(lbl = "Projekte", val = "project") ) output$filter_row <- renderUI({ cur <- type_filter() shiny::div(class = "d-flex gap-2 mb-3 flex-wrap", lapply(seq_along(FILTERS), function(i) { f <- FILTERS[[i]] active <- identical(cur, f$val) shiny::actionButton(ns(paste0("f_", i)), f$lbl, class = if (active) "btn btn-primary btn-sm" else "btn btn-outline-secondary btn-sm" ) }) ) }) observe({ lapply(seq_along(FILTERS), function(i) { local({ li <- i; val <- FILTERS[[li]]$val observeEvent(input[[paste0("f_", li)]], type_filter(val), ignoreInit = TRUE) }) }) }) # ---- Data ---------------------------------------------------------------- lists <- reactive({ reload() db_lists_get_all(db, include_closed = isTRUE(input$show_closed)) }) filtered <- reactive({ ls <- lists() tf <- type_filter() if (!is.null(tf) && tf != "") ls[ls$type == tf, ] else ls }) # ---- Card interactions --------------------------------------------------- observeEvent(input$expand, { id <- as.integer(input$expand) cur <- expanded() expanded(if (id %in% cur) setdiff(cur, id) else c(cur, id)) }) observeEvent(input$item_toggle, { req(input$item_toggle) db_items_set_checked(db, id = as.integer(input$item_toggle$id), checked = isTRUE(input$item_toggle$checked) ) reload(reload() + 1L) }) observeEvent(input$add_item, { req(input$add_item) txt <- trimws(input$add_item$text %||% "") if (nchar(txt) == 0) return() db_items_insert(db, list_id = as.integer(input$add_item$list_id), text = txt ) reload(reload() + 1L) }) observeEvent(input$close_list, { req(input$close_list) db_lists_close(db, id = as.integer(input$close_list)) reload(reload() + 1L) }) observeEvent(input$reopen_list, { req(input$reopen_list) db_lists_reopen(db, id = as.integer(input$reopen_list)) reload(reload() + 1L) }) observeEvent(input$delete_list, { req(input$delete_list) db_lists_delete(db, id = as.integer(input$delete_list)) expanded(setdiff(expanded(), as.integer(input$delete_list))) reload(reload() + 1L) }) # ---- Render cards -------------------------------------------------------- output$list_cards <- renderUI({ ls <- filtered() exp <- expanded() if (nrow(ls) == 0) return(shiny::p(class = "text-muted text-center py-4", "Keine Listen vorhanden.")) lapply(seq_len(nrow(ls)), function(i) { row <- ls[i, ] is_exp <- row$id %in% exp is_cls <- !is.na(row$closed_at) n_tot <- as.integer(row$item_count %||% 0L) n_chk <- as.integer(row$checked_count %||% 0L) items <- if (is_exp) db_items_by_list(db, row$id) else data.frame() type_cls <- switch(row$type, shopping = "bg-warning text-dark", todo = "bg-primary", project = "bg-info text-dark", "bg-secondary" ) type_lbl <- switch(row$type, shopping = "Einkaufen", todo = "Aufgaben", project = "Projekt", row$type ) # Header (always visible, tappable to expand) hdr <- shiny::div( class = paste("tap-card d-flex justify-content-between align-items-center py-2 px-3", if (is_cls) "bg-light" else "bg-white"), style = "border-bottom: 1px solid rgba(0,0,0,.125);", onclick = sprintf("Shiny.setInputValue('%s', %d, {priority:'event'})", ns("expand"), row$id), shiny::div(class = "d-flex align-items-center gap-2 overflow-hidden", shiny::HTML(sprintf('%s', type_cls, type_lbl)), shiny::tags$strong( class = paste("text-truncate", if (is_cls) "text-muted" else ""), row$name ), if (!is.na(row$t_shirt_size)) shiny::HTML(sprintf('%s', row$t_shirt_size)) else NULL ), shiny::div(class = "d-flex align-items-center gap-2 flex-shrink-0 ms-2", shiny::tags$small(class = "text-muted", if (n_tot > 0) sprintf("%d/%d", n_chk, n_tot) else "leer" ), shiny::icon(if (is_exp) "chevron-up" else "chevron-down", class = "text-muted") ) ) # Body (only when expanded) bdy <- if (is_exp) { item_rows <- if (nrow(items) > 0) { lapply(seq_len(n_tot), function(j) { it <- items[j, ] shiny::div( class = "item-row d-flex align-items-start gap-2 py-2 px-3", shiny::tags$input( type = "checkbox", class = "form-check-input mt-1 flex-shrink-0", style = "width:1.1rem;height:1.1rem;cursor:pointer", checked = if (isTRUE(it$checked)) NA else NULL, onclick = sprintf( "Shiny.setInputValue('%s',{id:%d,checked:this.checked},{priority:'event'})", ns("item_toggle"), it$id ) ), shiny::tags$span( class = if (isTRUE(it$checked)) "flex-grow-1 text-muted text-decoration-line-through" else "flex-grow-1", it$text ) ) }) } else { list(shiny::p(class = "text-muted small px-3 py-2 mb-0", "Noch keine Einträge.")) } inp_id <- sprintf("lk_inp_%d", row$id) add_row <- shiny::div(class = "d-flex gap-2 px-3 pt-2 pb-1", shiny::tags$input( type = "text", id = inp_id, class = "form-control form-control-sm", placeholder = "Neuer Eintrag...", onkeyup = sprintf( "if(event.key==='Enter'){var v=this.value.trim();if(v){Shiny.setInputValue('%s',{list_id:%d,text:v,ts:Date.now()},{priority:'event'});this.value='';}}", ns("add_item"), row$id ) ), shiny::tags$button( type = "button", class = "btn btn-outline-primary btn-sm flex-shrink-0", onclick = sprintf( "(function(i){var v=i.value.trim();if(v){Shiny.setInputValue('%s',{list_id:%d,text:v,ts:Date.now()},{priority:'event'});i.value='';}})(document.getElementById('%s'))", ns("add_item"), row$id, inp_id ), shiny::icon("plus") ) ) footer_btns <- shiny::div( class = "d-flex justify-content-between align-items-center px-3 py-2 bg-light border-top", if (!is_cls) { shiny::tags$button( type = "button", class = "btn btn-success btn-sm", onclick = sprintf("Shiny.setInputValue('%s',%d,{priority:'event'})", ns("close_list"), row$id), shiny::icon("check"), " Erledigt" ) } else { shiny::tags$button( type = "button", class = "btn btn-outline-secondary btn-sm", onclick = sprintf("Shiny.setInputValue('%s',%d,{priority:'event'})", ns("reopen_list"), row$id), shiny::icon("undo"), " Wieder öffnen" ) }, shiny::tags$button( type = "button", class = "btn btn-outline-danger btn-sm", onclick = sprintf( "if(confirm('Liste und alle Einträge löschen?'))Shiny.setInputValue('%s',%d,{priority:'event'})", ns("delete_list"), row$id ), shiny::icon("trash") ) ) shiny::div( shiny::tagList(item_rows), add_row, footer_btns ) } else NULL shiny::div(class = "card mb-2 shadow-sm", hdr, bdy) }) }) # ---- New list modal ------------------------------------------------------ observeEvent(input$new_btn, { shiny::showModal(shiny::modalDialog( title = "Neue Liste", footer = shiny::tagList( shiny::modalButton("Abbrechen"), shiny::actionButton(ns("confirm_new"), "Erstellen", class = "btn-primary") ), shiny::textInput(ns("new_name"), "Name *"), shiny::selectInput(ns("new_type"), "Typ", choices = c("Einkaufen" = "shopping", "Aufgaben" = "todo", "Projekt" = "project"), selected = "shopping" ), shiny::uiOutput(ns("new_extra_ui")) )) }) output$new_extra_ui <- renderUI({ req(input$new_type) if (input$new_type == "project") { shiny::selectInput(ns("new_size"), "Aufwand (T-Shirt-Größe)", choices = c("XS", "S", "M", "L", "XL"), selected = "M" ) } }) observeEvent(input$confirm_new, { name <- trimws(input$new_name %||% "") if (nchar(name) == 0) { shiny::showNotification("Bitte einen Namen eingeben.", type = "warning", duration = 3) return() } t_size <- if (!is.null(input$new_type) && input$new_type == "project") input$new_size else NA db_lists_insert(db, name = name, type = input$new_type, t_shirt_size = t_size ) shiny::removeModal() reload(reload() + 1L) }) }) }