package main import ( "errors" encrypt "fckeuspy-go/lib" "fmt" "image/color" "io" "os" "strings" "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/storage" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) type uiParts struct { outKey *widget.Entry msg *widget.Entry peer *widget.Entry cipherOut *widget.Entry payload *widget.Entry plainOut *widget.Entry toastLabel *widget.Label cipherQR *canvas.Image pubQR *canvas.Image crtQR *canvas.Image showQR bool peerQR *canvas.Image showPeerQR bool payloadQR *canvas.Image showPayloadQR bool peerContactID string // ID kontaktu vloženého do šifrování (pro přesnou identifikaci i při duplicitním CN) // callback nastavený v buildEncryptTab pro aktualizaci hlavičky peeru z jiných tabů (kontakty) updatePeerInfo func() } func buildEntries() *uiParts { p := &uiParts{ outKey: widget.NewMultiLineEntry(), msg: widget.NewMultiLineEntry(), peer: widget.NewMultiLineEntry(), cipherOut: widget.NewMultiLineEntry(), payload: widget.NewMultiLineEntry(), plainOut: widget.NewMultiLineEntry(), toastLabel: widget.NewLabel(""), cipherQR: canvas.NewImageFromImage(nil), pubQR: canvas.NewImageFromImage(nil), crtQR: canvas.NewImageFromImage(nil), showQR: true, peerQR: canvas.NewImageFromImage(nil), showPeerQR: true, payloadQR: canvas.NewImageFromImage(nil), showPayloadQR: true, } p.cipherQR.FillMode = canvas.ImageFillContain p.cipherQR.SetMinSize(fyne.NewSize(220, 220)) p.pubQR.FillMode = canvas.ImageFillContain p.pubQR.SetMinSize(fyne.NewSize(200, 200)) p.crtQR.FillMode = canvas.ImageFillContain p.crtQR.SetMinSize(fyne.NewSize(200, 200)) p.peerQR.FillMode = canvas.ImageFillContain p.peerQR.SetMinSize(fyne.NewSize(200, 200)) p.payloadQR.FillMode = canvas.ImageFillContain p.payloadQR.SetMinSize(fyne.NewSize(220, 220)) p.outKey.SetPlaceHolder("Veřejný klíč / certifikát…") p.msg.SetPlaceHolder("Sem napiš zprávu…") p.peer.SetPlaceHolder("-----BEGIN PUBLIC KEY----- … nebo CERTIFICATE …") p.cipherOut.SetPlaceHolder(`{"ek":"…","n":"…","ct":"…"}`) p.payload.SetPlaceHolder(`{"ek":"…","n":"…","ct":"…"}`) p.plainOut.SetPlaceHolder("Dešifrovaná zpráva…") // Zvýšení výšky (více řádků viditelně) p.outKey.SetMinRowsVisible(15) p.peer.SetMinRowsVisible(15) p.msg.SetMinRowsVisible(6) p.cipherOut.SetMinRowsVisible(15) p.payload.SetMinRowsVisible(15) p.plainOut.SetMinRowsVisible(15) p.toastLabel.Hide() return p } func (p *uiParts) showToast(msg string) { fyne.Do(func() { p.toastLabel.SetText(msg) p.toastLabel.Show() }) time.AfterFunc(1500*time.Millisecond, func() { fyne.Do(func() { if p.toastLabel.Text == msg { // avoid race if overwritten p.toastLabel.SetText("") p.toastLabel.Hide() } }) }) } // custom fixed dark theme type simpleTheme struct{} func (simpleTheme) Color(n fyne.ThemeColorName, v fyne.ThemeVariant) color.Color { base := theme.DefaultTheme().Color(n, v) switch n { case theme.ColorNameBackground: return color.NRGBA{R: 24, G: 27, B: 31, A: 255} case theme.ColorNameButton: return color.NRGBA{R: 42, G: 46, B: 52, A: 255} case theme.ColorNameInputBackground: return color.NRGBA{R: 35, G: 39, B: 45, A: 255} case theme.ColorNameDisabled: // Zesvětlený disabled text pro lepší čitelnost read-only polí return color.NRGBA{R: 230, G: 233, B: 238, A: 255} } return base } func (simpleTheme) Font(st fyne.TextStyle) fyne.Resource { return theme.DefaultTheme().Font(st) } func (simpleTheme) Icon(n fyne.ThemeIconName) fyne.Resource { return theme.DefaultTheme().Icon(n) } func (simpleTheme) Size(n fyne.ThemeSizeName) float32 { return theme.DefaultTheme().Size(n) } var forceDark = true // can be toggled later // Build key section func buildIdentityTab(parts *uiParts, svc ServiceFacade, vaultPath string) fyne.CanvasObject { // Toolbar actions identityToggle := widget.NewToolbarAction(theme.VisibilityOffIcon(), nil) identityToolbar := widget.NewToolbar( widget.NewToolbarAction(theme.ContentCopyIcon(), func() { copyClip(svc.PublicPEM()+"\n"+svc.PublicCert(), parts) }), identityToggle, ) deleteBtn := widget.NewButton("Smazat identitu", func() { pwEntry := widget.NewPasswordEntry() pwEntry.SetPlaceHolder("Heslo pro potvrzení…") warn := widget.NewRichTextFromMarkdown("**⚠ Nevratná akce**\n\nTato operace trvale smaže identitu, kontakty i uložené klíče. Pokračujte pouze pokud máte zálohu nebo opravdu chcete vše odstranit.\n") warn.Wrapping = fyne.TextWrapWord form := widget.NewForm(widget.NewFormItem("Heslo", pwEntry)) content := container.NewVBox( warn, widget.NewSeparator(), form, ) d := dialog.NewCustomConfirm("Potvrdit smazání", "Smazat", "Zrušit", content, func(ok bool) { if !ok { return } pw := pwEntry.Text if pw == "" { dialog.NewError(errors.New("heslo je prázdné"), fyne.CurrentApp().Driver().AllWindows()[0]).Show() return } if _, err := encrypt.OpenEncryptedStore(vaultPath, pw); err != nil { dialog.NewError(errors.New("neplatné heslo"), fyne.CurrentApp().Driver().AllWindows()[0]).Show() return } if err := os.Remove(vaultPath); err != nil { dialog.NewError(err, fyne.CurrentApp().Driver().AllWindows()[0]).Show() return } fyne.CurrentApp().Quit() }, fyne.CurrentApp().Driver().AllWindows()[0]) d.Resize(fyne.NewSize(600, 250)) d.Show() }) makeQR := func(data string, target *canvas.Image) { if data == "" { target.Image = nil target.Refresh() return } pngBytes, err := GenerateQRPNG(data, 512) if err != nil { return } img, err := LoadPNG(pngBytes) if err != nil { return } target.Image = img target.Refresh() } updateQRImages := func() { if parts.showQR { makeQR(svc.PublicPEM(), parts.pubQR) makeQR(svc.PublicCert(), parts.crtQR) } else { parts.pubQR.Image = nil parts.pubQR.Refresh() parts.crtQR.Image = nil parts.crtQR.Refresh() } } identityContainer := container.NewVBox() rebuild := func() { identityContainer.Objects = nil if parts.showQR { updateQRImages() // Wrap each QR with a small icon copy button pubBox := container.NewVBox( widget.NewLabel("Veřejný klíč (PEM)"), parts.pubQR, widget.NewButtonWithIcon("", theme.ContentCopyIcon(), func() { copyClip(svc.PublicPEM(), parts) }), ) crtBox := container.NewVBox( widget.NewLabel("Certifikát (X.509)"), parts.crtQR, widget.NewButtonWithIcon("", theme.ContentCopyIcon(), func() { copyClip(svc.PublicCert(), parts) }), ) identityContainer.Add(container.NewGridWithColumns(2, pubBox, crtBox)) } else { // show combined text for convenience parts.outKey.SetText(svc.PublicPEM() + "\n" + svc.PublicCert()) parts.outKey.Disable() identityContainer.Add(container.NewVBox( widget.NewLabel("Veřejný klíč + Certifikát (plaintext)"), parts.outKey, )) } identityContainer.Refresh() if parts.showQR { identityToggle.SetIcon(theme.VisibilityOffIcon()) } else { identityToggle.SetIcon(theme.VisibilityIcon()) } } identityToggle.OnActivated = func() { parts.showQR = !parts.showQR; rebuild() } rebuild() // Header with toolbar and content + destructive action header := container.NewHBox(widget.NewLabelWithStyle("Moje identita", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), layout.NewSpacer(), identityToolbar) content := container.NewVBox(header, identityContainer, buttonTile(deleteBtn)) return container.NewVScroll(content) } // Helper functions separated to avoid circular dependency with encrypt.Service // We'll adapt by passing a small facade interface if needed. type ServiceFacade interface { Encrypt(msg, peer string) (string, error) Decrypt(json string) (string, error) PublicPEM() string PublicCert() string // Contacts API (only implemented by VaultService) ListContacts() ([]Contact, error) SaveContact(c Contact) error DeleteContact(id string) error } func copyClip(s string, parts *uiParts) { fyne.CurrentApp().Clipboard().SetContent(s) parts.showToast("Zkopírováno") } // (Removed legacy buildEncryptSection and buildDecryptSection) // assembleResponsive builds split view that collapses for narrow widths // Tab: Encrypt // buildEncryptTab odstraněn – šifrování nyní pouze přes popup z listu kontaktů. // Tab: Decrypt func buildDecryptTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { parts.plainOut.Disable() decryptAction := func() { pl := parts.payload.Text if pl == "" { parts.plainOut.SetText("") return } go func(js string) { res, err := svc.Decrypt(js) if err != nil { fyne.Do(func() { parts.plainOut.SetText("") }) return } fyne.Do(func() { parts.plainOut.SetText(res) }) }(pl) } updatePayloadQR := func(text string) { if text == "" { parts.payloadQR.Image = nil parts.payloadQR.Refresh() return } pngBytes, err := GenerateQRPNG(text, 512) if err != nil { return } img, err := LoadPNG(pngBytes) if err != nil { return } parts.payloadQR.Image = img parts.payloadQR.Refresh() } importPayloadQR := func() { fd := dialog.NewFileOpen(func(r fyne.URIReadCloser, err error) { if err != nil || r == nil { return } defer r.Close() data, _ := io.ReadAll(r) img, err := LoadPNG(data) if err != nil { dialog.NewError(err, fyne.CurrentApp().Driver().AllWindows()[0]).Show() return } text, err := DecodeQR(img) if err != nil { dialog.NewError(err, fyne.CurrentApp().Driver().AllWindows()[0]).Show() return } parts.payload.SetText(text) if parts.showPayloadQR { updatePayloadQR(text) } decryptAction() }, fyne.CurrentApp().Driver().AllWindows()[0]) fd.SetFilter(storage.NewExtensionFileFilter([]string{".png"})) fd.Show() } // Toolbar toggle action for payload section payloadToggleAction := widget.NewToolbarAction(theme.VisibilityOffIcon(), nil) payloadContainer := container.NewVBox() updateMode := func() { payloadContainer.Objects = nil if parts.showPayloadQR { updatePayloadQR(parts.payload.Text) // Jen samotný QR bez spodního copy tlačítka payloadContainer.Add(parts.payloadQR) payloadToggleAction.SetIcon(theme.VisibilityOffIcon()) } else { payloadContainer.Add(parts.payload) payloadToggleAction.SetIcon(theme.VisibilityIcon()) } payloadContainer.Refresh() } payloadToggleAction.OnActivated = func() { parts.showPayloadQR = !parts.showPayloadQR; updateMode() } parts.payload.OnChanged = func(string) { if parts.showPayloadQR { updatePayloadQR(parts.payload.Text) } decryptAction() } updateMode() // Build payload toolbar payloadToolbar := widget.NewToolbar( widget.NewToolbarAction(theme.ContentPasteIcon(), func() { clip := fyne.CurrentApp().Clipboard().Content() parts.payload.SetText(clip) }), widget.NewToolbarAction(theme.ContentCopyIcon(), func() { copyClip(parts.payload.Text, parts) }), widget.NewToolbarAction(theme.FolderOpenIcon(), importPayloadQR), widget.NewToolbarAction(theme.ContentClearIcon(), func() { parts.payload.SetText("") }), widget.NewToolbarSeparator(), payloadToggleAction, ) payloadSection := container.NewVBox( container.NewHBox(widget.NewLabel("Payload"), layout.NewSpacer(), payloadToolbar), payloadContainer, ) resultHeader := container.NewHBox(widget.NewLabel("Výsledek"), layout.NewSpacer()) plainWrap := container.NewBorder(resultHeader, nil, nil, nil, parts.plainOut) split := container.NewVSplit(payloadSection, plainWrap) split.SetOffset(0.30) group := container.NewVBox( widget.NewLabelWithStyle("Dešifrování", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), split, ) return group } func buildTabbedUI(parts *uiParts, svc ServiceFacade, vaultPath string) fyne.CanvasObject { idTab := container.NewTabItem("Identita", buildIdentityTab(parts, svc, vaultPath)) contactsTab := container.NewTabItem("Kontakty", buildContactsTab(parts, svc)) // Odebrána samostatná záložka Šifrování – nahrazena popupem z listu kontaktů. decTab := container.NewTabItem("Dešifrování", buildDecryptTab(parts, svc)) tabs := container.NewAppTabs(idTab, contactsTab, decTab) tabs.SetTabLocation(container.TabLocationTop) // apply theme; allow toggle via hidden shortcut Ctrl+T (demo) fyne.CurrentApp().Settings().SetTheme(simpleTheme{}) // register a shortcut to toggle QR/text globally (Ctrl+Q) and encrypt/decrypt (Ctrl+Enter inside tab) // (Shortcut toggle removed - API placeholder, lze doplnit přes desktop accelerator) prefs := fyne.CurrentApp().Preferences() // Vždy start na identitě (index 0), ignoruje předchozí uloženou pozici tabs.SelectIndex(0) tabs.OnSelected = func(ti *container.TabItem) { if prefs != nil { prefs.SetInt("lastTab", tabs.SelectedIndex()) } } return container.NewBorder(nil, parts.toastLabel, nil, nil, tabs) } // buttonTile renders buttons above a related entry with a colored background spanning full width func buttonTile(btns ...fyne.CanvasObject) fyne.CanvasObject { if len(btns) == 0 { return widget.NewLabel("") } cols := len(btns) if cols > 3 { cols = 3 } // wrap into multiple rows if many grid := container.NewGridWithColumns(cols, btns...) bgColor := color.NRGBA{R: 240, G: 240, B: 245, A: 255} if forceDark { bgColor = color.NRGBA{R: 50, G: 54, B: 60, A: 255} } rect := canvas.NewRectangle(bgColor) rect.SetMinSize(fyne.NewSize(100, 38)) padded := container.NewPadded(grid) return container.NewStack(rect, padded) } // --- Contacts UI --- func buildContactsTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { // Data + filtering var all []Contact var filtered []Contact selected := -1 // index into filtered (real contacts only, not counting draft) var draft *Contact // transient unsaved contact placeholder; not persisted until save load := func() { items, _ := svc.ListContacts() all = items filtered = items } applyFilter := func(q string) { if q == "" { filtered = all return } lower := strings.ToLower(q) tmp := make([]Contact, 0, len(all)) for _, c := range all { if strings.Contains(strings.ToLower(c.Name), lower) || strings.Contains(strings.ToLower(c.Cert), lower) { tmp = append(tmp, c) } } filtered = tmp } // Inline popup builder for encryption openEncryptPopup := func(ct Contact) { // Minimalist popup: jméno kontaktu, zpráva, tlačítko Šifrovat, pouze QR kód výsledku msgEntry := widget.NewMultiLineEntry() msgEntry.SetPlaceHolder("Zpráva…") msgEntry.SetMinRowsVisible(6) qrImg := canvas.NewImageFromImage(nil) qrImg.FillMode = canvas.ImageFillContain qrImg.SetMinSize(fyne.NewSize(260, 260)) status := widget.NewLabel("") status.Wrapping = fyne.TextWrapWord updateQR := func(text string) { if strings.TrimSpace(text) == "" { qrImg.Image = nil qrImg.Refresh() return } if b, err := GenerateQRPNG(text, 512); err == nil { if im, err2 := LoadPNG(b); err2 == nil { qrImg.Image = im qrImg.Refresh() } } } var win fyne.Window = fyne.CurrentApp().Driver().AllWindows()[0] lastCipher := "" doEncrypt := func() { m := strings.TrimSpace(msgEntry.Text) fyne.Do(func() { if m == "" { status.SetText("Zpráva je prázdná") lastCipher = "" updateQR("") return } status.SetText("Šifruji…") }) if m == "" { // už jsme UI vyřešili return } go func(txt string) { res, err := svc.Encrypt(txt, ct.Cert) if err != nil { fyne.Do(func() { status.SetText("Chyba: " + err.Error()) }) return } fyne.Do(func() { lastCipher = res updateQR(res) status.SetText("Hotovo") }) }(m) } var autoTimer *time.Timer msgEntry.OnChanged = func(string) { if autoTimer != nil { autoTimer.Stop() } autoTimer = time.AfterFunc(300*time.Millisecond, doEncrypt) } copyBtn := widget.NewButton("Kopírovat payload", func() { if lastCipher != "" { copyClip(lastCipher, parts) status.SetText("Zkopírováno") } }) popupContent := container.NewVBox( widget.NewLabel("Zpráva"), msgEntry, widget.NewSeparator(), container.NewHBox(widget.NewLabel("QR kód"), layout.NewSpacer(), copyBtn), qrImg, status, ) cn := extractCN(ct.Cert) titleName := ct.Name if titleName == "" { titleName = "(bez názvu)" } if cn != "" && !strings.Contains(titleName, cn) { titleName = fmt.Sprintf("%s (%s)", titleName, cn) } popup := dialog.NewCustom(fmt.Sprintf("Poslat zprávu: %s", titleName), "Zavřít", popupContent, win) popup.Resize(fyne.NewSize(640, 520)) popup.Show() } // Left list (includes optional draft as first row) — custom row with label + button list := widget.NewList( func() int { if draft != nil { return len(filtered) + 1 } return len(filtered) }, func() fyne.CanvasObject { lbl := widget.NewLabel("") btn := widget.NewButton("Poslat zprávu", nil) btn.Importance = widget.LowImportance row := container.NewBorder(nil, nil, nil, btn, lbl) return row }, func(i widget.ListItemID, o fyne.CanvasObject) { row := o.(*fyne.Container) lbl := row.Objects[0].(*widget.Label) btn := row.Objects[1].(*widget.Button) if draft != nil { if int(i) == 0 { name := draft.Name if strings.TrimSpace(name) == "" { name = "(nový)" } cn := extractCN(draft.Cert) if cn != "" { lbl.SetText(fmt.Sprintf("✳ %s (nový) (%s)", name, cn)) } else { lbl.SetText("✳ " + name + " (nový)") } btn.Disable() btn.OnTapped = nil return } real := int(i) - 1 if real >= 0 && real < len(filtered) { c := filtered[real] name := c.Name if name == "" { name = "(bez názvu)" } cn := extractCN(c.Cert) if cn != "" { lbl.SetText(fmt.Sprintf("%s (%s)", name, cn)) } else { lbl.SetText(name) } btn.Enable() btn.OnTapped = func() { openEncryptPopup(c) } } return } if i >= 0 && int(i) < len(filtered) { c := filtered[i] name := c.Name if name == "" { name = "(bez názvu)" } cn := extractCN(c.Cert) if cn != "" { lbl.SetText(fmt.Sprintf("%s (%s)", name, cn)) } else { lbl.SetText(name) } btn.Enable() btn.OnTapped = func() { openEncryptPopup(c) } } }, ) noResults := widget.NewLabel("Žádné kontakty") noResults.Alignment = fyne.TextAlignCenter noResults.Hide() // searchQuery drží poslední text vyhledávání – empty state jen pokud není prázdný a nic nenalezeno searchQuery := "" updateEmptyState := func() { // pokud je draft aktivní, neukazovat prázdný stav if draft != nil { noResults.Hide() return } if len(filtered) == 0 && searchQuery != "" { noResults.Show() } else { noResults.Hide() } } // Right detail form nameEntry := widget.NewEntry() certEntry := widget.NewMultiLineEntry() certEntry.SetMinRowsVisible(8) certEntry.Wrapping = fyne.TextWrapWord // (Původní okamžité dotazování na CN při vložení zrušeno – nyní se ptáme až při ukládání) // Detail toolbar pasteAction := widget.NewToolbarAction(theme.ContentPasteIcon(), func() { clip := fyne.CurrentApp().Clipboard().Content() if strings.TrimSpace(clip) == "" { parts.showToast("Schránka prázdná") return } certEntry.SetText(clip) parts.showToast("Vloženo ze schránky") }) certToolbar := widget.NewToolbar( widget.NewToolbarAction(theme.ContentCopyIcon(), func() { copyClip(certEntry.Text, parts) }), pasteAction, widget.NewToolbarAction(theme.ContentClearIcon(), func() { certEntry.SetText("") }), ) // helper pro generování default názvu makeDefaultName := func() string { base := "Nový kontakt" // pokud neexistuje žádný s přesným názvem, vrať jen base existsExact := false maxN := 1 for _, c := range all { if c.Name == base { existsExact = true } if strings.HasPrefix(c.Name, base+" ") { // varianty s číslem var n int if _, err := fmt.Sscanf(c.Name, "Nový kontakt %d", &n); err == nil { if n >= maxN { maxN = n + 1 } } } } if !existsExact { return base } return fmt.Sprintf("%s %d", base, maxN) } // search entry (musí být před saveBtn kvůli použití) search := widget.NewEntry() search.SetPlaceHolder("Hledat…") saveBtn := widget.NewButtonWithIcon("Uložit", theme.ConfirmIcon(), func() { name := strings.TrimSpace(nameEntry.Text) cert := strings.TrimSpace(certEntry.Text) if cert == "" { parts.showToast("Chybí cert") return } cn := extractCN(cert) // always ask if CN exists and (a) name empty/reserved OR (b) name differs from CN shouldAsk := cn != "" && (name == "" || name == "Kontakt" || name == "Nový kontakt" || name != cn) proceed := func(finalName string) { if finalName == "" || finalName == "Kontakt" || finalName == "Nový kontakt" { finalName = makeDefaultName() } if draft != nil { // saving draft as new contact _ = svc.SaveContact(Contact{Name: finalName, Cert: cert}) draft = nil searchQuery = "" // clear filter to show new search.SetText("") } else if selected < 0 || selected >= len(filtered) { // new without draft (fallback) _ = svc.SaveContact(Contact{Name: finalName, Cert: cert}) searchQuery = "" search.SetText("") } else { // update existing c := filtered[selected] c.Name = finalName c.Cert = cert _ = svc.SaveContact(c) } load() applyFilter(searchQuery) list.Refresh() updateEmptyState() parts.showToast("Uloženo") } if shouldAsk { // build message depending on situation msg := fmt.Sprintf("Certifikát obsahuje Common Name:\n\n%s\n\nPoužít ho jako název kontaktu? (Aktuálně: %q)", cn, name) dialog.NewCustomConfirm("Common Name", "Použít", "Ponechat", widget.NewLabel(msg), func(ok bool) { if ok { proceed(cn) } else { proceed(name) } }, fyne.CurrentApp().Driver().AllWindows()[0]).Show() return } proceed(name) }) useBtn := widget.NewButtonWithIcon("Použít ve Šifrování", theme.ConfirmIcon(), func() { // Určit aktuální kontakt (draft nebo vybraný reálný) var currentID string if draft != nil && selected == -1 { currentID = draft.ID } else if selected >= 0 && selected < len(filtered) { currentID = filtered[selected].ID } // Vždy vynutit změnu: smazat a hned nastavit parts.peer.SetText("") parts.peerContactID = "" // reset aby updatePeerInfo neukazoval staré jméno fyne.Do(func() { parts.peer.SetText(certEntry.Text) parts.peerContactID = currentID // Force refresh hlavičky peeru i v případě, že se formát certu liší (trim apod.) if parts.updatePeerInfo != nil { parts.updatePeerInfo() } }) parts.showToast("Kontakt vložen do Šifrování") }) certRow := container.NewHBox(certToolbar, layout.NewSpacer(), saveBtn, useBtn) var detail fyne.CanvasObject detail = container.NewBorder( container.NewVBox( widget.NewLabel("Název"), nameEntry, widget.NewLabel("Certifikát / Public key"), certRow, ), nil, nil, nil, certEntry, ) // List selection behavior list.OnSelected = func(id widget.ListItemID) { idx := int(id) if draft != nil { if idx == 0 { // selecting draft row selected = -1 nameEntry.SetText(draft.Name) certEntry.SetText(draft.Cert) return } idx = idx - 1 // shift for real contacts } if idx < 0 || idx >= len(filtered) { return } selected = idx // cancel draft if switching away if draft != nil { draft = nil list.Refresh() } c := filtered[idx] nameEntry.SetText(c.Name) certEntry.SetText(c.Cert) } list.OnUnselected = func(widget.ListItemID) { selected = -1 } // Header: title, search, toolbar search.OnChanged = func(s string) { searchQuery = s applyFilter(s) selected = -1 // keep draft row if present; clear form only if no draft if draft == nil { nameEntry.SetText("") certEntry.SetText("") } list.Refresh() updateEmptyState() } addNewLabelBtn := widget.NewButtonWithIcon("Vytvořit nový", theme.ContentAddIcon(), func() { // If draft already exists just reselect it if draft != nil { list.Select(0) return } // remove selection of any existing contact selected = -1 list.UnselectAll() // create draft placeholder draft = &Contact{ID: "__draft__", Name: makeDefaultName(), Cert: ""} nameEntry.SetText(draft.Name) certEntry.SetText("") // Pokud je ve schránce PEM / CERT, automaticky ho vlož if clip := fyne.CurrentApp().Clipboard().Content(); strings.Contains(clip, "BEGIN ") && strings.Contains(clip, "PUBLIC") || strings.Contains(clip, "CERTIFICATE") { certEntry.SetText(clip) } // auto select all text in name so user can immediately přepsat fyne.CurrentApp().Driver().CanvasForObject(nameEntry).Focus(nameEntry) nameEntry.TypedShortcut(&fyne.ShortcutSelectAll{}) // ensure filter cleared so user sees draft on top searchQuery = "" search.SetText("") list.Refresh() list.Select(0) updateEmptyState() parts.showToast("Nový kontakt (draft)") }) // (Odstraněno tlačítko 'vložit z clipboardu' z horní lišty podle požadavku) addFromFile := widget.NewToolbarAction(theme.FolderOpenIcon(), func() { fd := dialog.NewFileOpen(func(r fyne.URIReadCloser, err error) { if err != nil || r == nil { return } defer r.Close() b, _ := io.ReadAll(r) txt := string(b) certEntry.SetText(txt) // OnChanged will offer CN usage if present if extractCN(txt) == "" && strings.TrimSpace(nameEntry.Text) == "" { nameEntry.SetText("Nový kontakt") } selected = -1 }, fyne.CurrentApp().Driver().AllWindows()[0]) fd.Show() }) // Live update draft placeholder name as user types nameEntry.OnChanged = func(s string) { if draft != nil { draft.Name = s list.Refresh() } } // Delete button přesunuto k Uložit / Použít deleteBtn := widget.NewButtonWithIcon("", theme.DeleteIcon(), func() { // If draft active and no real contact chosen -> cancel draft if draft != nil && selected == -1 { // remove draft and if exist some contacts immediately select first one draft = nil list.Refresh() updateEmptyState() if len(filtered) > 0 { selected = 0 c := filtered[0] nameEntry.SetText(c.Name) certEntry.SetText(c.Cert) list.Select(0) } else { selected = -1 nameEntry.SetText("") certEntry.SetText("") } parts.showToast("Zrušeno") return } if selected < 0 || selected >= len(filtered) { return } c := filtered[selected] confirmMsg := fmt.Sprintf("Opravdu smazat kontakt %q?", c.Name) dialog.NewConfirm("Smazat kontakt", confirmMsg, func(ok bool) { if !ok { return } _ = svc.DeleteContact(c.ID) load() applyFilter(search.Text) selected = -1 nameEntry.SetText("") certEntry.SetText("") list.Refresh() updateEmptyState() parts.showToast("Smazáno") }, fyne.CurrentApp().Driver().AllWindows()[0]).Show() }) // Přestavění certRow aby obsahoval i deleteBtn (nahrazení původního detailu níže) certRow = container.NewHBox(certToolbar, layout.NewSpacer(), saveBtn, useBtn, deleteBtn) // aktualizace detail panelu: nahradíme předchozí variantu detail = container.NewBorder( container.NewVBox( widget.NewLabel("Název"), nameEntry, widget.NewLabel("Certifikát / Public key"), certRow, ), nil, nil, nil, certEntry, ) // Horní toolbar nyní jen se souborem (import z file) topToolbar := widget.NewToolbar(addFromFile) header := container.NewHBox(widget.NewLabelWithStyle("Kontakty", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), layout.NewSpacer(), addNewLabelBtn, topToolbar) // Left side: search fixed top, list fills remaining (widget.List scrolluje sama); overlay noResults left := container.NewBorder(search, nil, nil, nil, container.NewStack(list, noResults)) right := container.NewStack(detail) split := container.NewHSplit(left, right) split.SetOffset(0.35) // Initialize load() updateEmptyState() list.Refresh() return container.NewBorder(header, nil, nil, nil, split) }