fckeuspy-go/ui.go

921 lines
27 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
}
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í…")
content := widget.NewForm(widget.NewFormItem("Heslo", pwEntry))
dialog.ShowCustomConfirm("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])
})
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
func buildEncryptTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject {
parts.cipherOut.Disable()
// Peer section with QR/Text toggle
peerContainer := container.NewVBox()
peerToggleAction := widget.NewToolbarAction(theme.VisibilityOffIcon(), nil)
var updatePeer func()
updatePeerQR := func(text string) {
if text == "" {
parts.peerQR.Image = nil
parts.peerQR.Refresh()
return
}
pngBytes, err := GenerateQRPNG(text, 512)
if err != nil {
return
}
img, err := LoadPNG(pngBytes)
if err != nil {
return
}
parts.peerQR.Image = img
parts.peerQR.Refresh()
}
updatePeer = func() {
peerContainer.Objects = nil
if parts.showPeerQR {
updatePeerQR(parts.peer.Text)
peerContainer.Add(parts.peerQR) // copy tlačítko jen v toolbaru
peerToggleAction.SetIcon(theme.VisibilityOffIcon())
} else {
peerContainer.Add(parts.peer)
peerToggleAction.SetIcon(theme.VisibilityIcon())
}
peerContainer.Refresh()
}
peerToggleAction.OnActivated = func() { parts.showPeerQR = !parts.showPeerQR; updatePeer() }
parts.peer.OnChanged = func(string) {
if parts.showPeerQR {
updatePeerQR(parts.peer.Text)
}
}
updatePeer()
// Output section with QR/Text toggle
outputContainer := container.NewVBox()
outputToggleAction := widget.NewToolbarAction(theme.VisibilityOffIcon(), nil)
updateQR := func(text string) {
if text == "" {
parts.cipherQR.Image = nil
parts.cipherQR.Refresh()
return
}
pngBytes, err := GenerateQRPNG(text, 512)
if err != nil {
parts.showToast("QR error")
return
}
img, err := LoadPNG(pngBytes)
if err != nil {
parts.showToast("PNG err")
return
}
parts.cipherQR.Image = img
parts.cipherQR.Refresh()
}
updateMode := func() {
outputContainer.Objects = nil
if parts.showQR {
updateQR(parts.cipherOut.Text)
outputContainer.Add(parts.cipherQR) // copy tlačítko jen v toolbaru
outputToggleAction.SetIcon(theme.VisibilityOffIcon())
} else {
outputContainer.Add(parts.cipherOut)
outputToggleAction.SetIcon(theme.VisibilityIcon())
}
outputContainer.Refresh()
}
outputToggleAction.OnActivated = func() { parts.showQR = !parts.showQR; updateMode() }
updateMode()
encAction := func() {
m := parts.msg.Text
p := parts.peer.Text
if m == "" || p == "" {
parts.showToast("Chybí data")
return
}
go func(msg, peer string) {
res, err := svc.Encrypt(msg, peer)
if err != nil {
fyne.Do(func() { parts.cipherOut.SetText(""); updateQR(""); parts.showToast("Chyba") })
return
}
fyne.Do(func() {
parts.cipherOut.SetText(res)
if parts.showQR {
updateQR(res)
}
parts.showToast("OK")
})
}(m, p)
}
importPeerQR := 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.peer.SetText(text)
}, fyne.CurrentApp().Driver().AllWindows()[0])
fd.SetFilter(storage.NewExtensionFileFilter([]string{".png"}))
fd.Show()
}
// Toolbars
peerToolbar := widget.NewToolbar(
widget.NewToolbarAction(theme.ContentPasteIcon(), func() { parts.peer.SetText(fyne.CurrentApp().Clipboard().Content()) }),
widget.NewToolbarAction(theme.ContentCopyIcon(), func() { copyClip(parts.peer.Text, parts) }),
widget.NewToolbarAction(theme.FolderOpenIcon(), importPeerQR),
widget.NewToolbarAction(theme.ContentClearIcon(), func() { parts.peer.SetText("") }),
widget.NewToolbarSeparator(),
peerToggleAction,
)
outputToolbar := widget.NewToolbar(
widget.NewToolbarAction(theme.ContentCopyIcon(), func() { copyClip(parts.cipherOut.Text, parts) }),
widget.NewToolbarAction(theme.ContentClearIcon(), func() {
parts.cipherOut.SetText("")
parts.msg.SetText("")
if parts.showQR {
updateQR("")
}
}),
widget.NewToolbarSeparator(),
outputToggleAction,
)
// Šifrování automaticky při změně zprávy nebo peer
parts.msg.OnChanged = func(string) { encAction() }
parts.peer.OnChanged = func(string) {
if parts.showPeerQR {
updatePeerQR(parts.peer.Text)
}
encAction()
}
// Sections
peerSection := container.NewVBox(
container.NewHBox(widget.NewLabel("Veřejný klíč příjemce"), layout.NewSpacer(), peerToolbar),
peerContainer,
)
msgSection := container.NewVBox(
widget.NewLabel("Zpráva"),
parts.msg,
)
outputSection := container.NewVBox(
container.NewHBox(widget.NewLabel("Výstup"), layout.NewSpacer(), outputToolbar),
outputContainer,
)
group := container.NewVBox(
widget.NewLabelWithStyle("Šifrování", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
peerSection,
msgSection,
outputSection,
)
return container.NewVScroll(group)
}
// 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))
encTab := container.NewTabItem("Šifrování", buildEncryptTab(parts, svc))
decTab := container.NewTabItem("Dešifrování", buildDecryptTab(parts, svc))
tabs := container.NewAppTabs(idTab, contactsTab, encTab, 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
}
// Left list (includes optional draft as first row)
list := widget.NewList(
func() int {
if draft != nil {
return len(filtered) + 1
}
return len(filtered)
},
func() fyne.CanvasObject { return widget.NewLabel("") },
func(i widget.ListItemID, o fyne.CanvasObject) {
if draft != nil {
if int(i) == 0 { // draft row
name := draft.Name
if strings.TrimSpace(name) == "" {
name = "(nový)"
}
o.(*widget.Label).SetText("✳ " + name + " (nový)")
return
}
// shift index for real contacts
real := int(i) - 1
if real >= 0 && real < len(filtered) {
name := filtered[real].Name
if name == "" {
name = "(bez názvu)"
}
o.(*widget.Label).SetText(name)
}
return
}
// no draft
if i >= 0 && int(i) < len(filtered) {
name := filtered[i].Name
if name == "" {
name = "(bez názvu)"
}
o.(*widget.Label).SetText(name)
}
},
)
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
// Detail toolbar
certToolbar := widget.NewToolbar(
widget.NewToolbarAction(theme.ContentCopyIcon(), func() { copyClip(certEntry.Text, parts) }),
widget.NewToolbarAction(theme.ContentPasteIcon(), func() {
certEntry.SetText(fyne.CurrentApp().Clipboard().Content())
}),
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
}
if name == "" || name == "Kontakt" || name == "Nový kontakt" {
name = makeDefaultName()
}
if draft != nil { // saving draft as new contact
_ = svc.SaveContact(Contact{Name: name, 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: name, Cert: cert})
searchQuery = ""
search.SetText("")
} else { // update existing
c := filtered[selected]
c.Name = name
c.Cert = cert
_ = svc.SaveContact(c)
}
load()
applyFilter(searchQuery)
list.Refresh()
updateEmptyState()
parts.showToast("Uloženo")
})
useBtn := widget.NewButtonWithIcon("Použít ve Šifrování", theme.ConfirmIcon(), func() {
parts.peer.SetText(certEntry.Text)
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("")
// 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)
nm := extractCN(txt)
if nm == "" {
nm = "Nový kontakt"
}
nameEntry.SetText(nm)
certEntry.SetText(txt)
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 {
draft = nil
nameEntry.SetText("")
certEntry.SetText("")
list.Refresh()
updateEmptyState()
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)
}