fckeuspy-go/ui.go
2025-09-25 23:24:34 +02:00

931 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}