diff --git a/ui.go b/ui.go index 616f6f8..1d77ba2 100644 --- a/ui.go +++ b/ui.go @@ -3,6 +3,7 @@ package main import ( "errors" encrypt "fckeuspy-go/lib" + "fmt" "image/color" "io" "os" @@ -72,12 +73,12 @@ func buildEntries() *uiParts { p.payload.SetPlaceHolder(`{"ek":"…","n":"…","ct":"…"}`) p.plainOut.SetPlaceHolder("Dešifrovaná zpráva…") // Zvýšení výšky (více řádků viditelně) - p.outKey.SetMinRowsVisible(6) - p.peer.SetMinRowsVisible(4) - p.msg.SetMinRowsVisible(5) - p.cipherOut.SetMinRowsVisible(5) - p.payload.SetMinRowsVisible(5) - p.plainOut.SetMinRowsVisible(5) + 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 } @@ -102,8 +103,16 @@ type simpleTheme struct{} func (simpleTheme) Color(n fyne.ThemeColorName, v fyne.ThemeVariant) color.Color { base := theme.DefaultTheme().Color(n, v) - if n == theme.ColorNameBackground || n == theme.ColorNameButton { - return color.NRGBA{R: 30, G: 34, B: 39, A: 255} + 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 } @@ -111,7 +120,7 @@ func (simpleTheme) Font(st fyne.TextStyle) fyne.Resource { return theme.Defau 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 +var forceDark = true // can be toggled later // Build key section func buildIdentityTab(parts *uiParts, svc ServiceFacade, vaultPath string) fyne.CanvasObject { @@ -181,16 +190,16 @@ func buildIdentityTab(parts *uiParts, svc ServiceFacade, vaultPath string) fyne. identityContainer.Objects = nil if parts.showQR { updateQRImages() - // Wrap each QR with its copy button + // Wrap each QR with a small icon copy button pubBox := container.NewVBox( widget.NewLabel("Veřejný klíč (PEM)"), parts.pubQR, - widget.NewButton("Copy", func() { copyClip(svc.PublicPEM(), parts) }), + widget.NewButtonWithIcon("", theme.ContentCopyIcon(), func() { copyClip(svc.PublicPEM(), parts) }), ) crtBox := container.NewVBox( widget.NewLabel("Certifikát (X.509)"), parts.crtQR, - widget.NewButton("Copy", func() { copyClip(svc.PublicCert(), parts) }), + widget.NewButtonWithIcon("", theme.ContentCopyIcon(), func() { copyClip(svc.PublicCert(), parts) }), ) identityContainer.Add(container.NewGridWithColumns(2, pubBox, crtBox)) } else { @@ -269,7 +278,7 @@ func buildEncryptTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { peerContainer.Objects = nil if parts.showPeerQR { updatePeerQR(parts.peer.Text) - peerContainer.Add(container.NewVBox(parts.peerQR, widget.NewButton("Copy", func() { copyClip(parts.peer.Text, parts) }))) + peerContainer.Add(parts.peerQR) // copy tlačítko jen v toolbaru peerToggleAction.SetIcon(theme.VisibilityOffIcon()) } else { peerContainer.Add(parts.peer) @@ -311,7 +320,7 @@ func buildEncryptTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { outputContainer.Objects = nil if parts.showQR { updateQR(parts.cipherOut.Text) - outputContainer.Add(container.NewVBox(parts.cipherQR, widget.NewButton("Copy", func() { copyClip(parts.cipherOut.Text, parts) }))) + outputContainer.Add(parts.cipherQR) // copy tlačítko jen v toolbaru outputToggleAction.SetIcon(theme.VisibilityOffIcon()) } else { outputContainer.Add(parts.cipherOut) @@ -371,20 +380,33 @@ func buildEncryptTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { // Toolbars peerToolbar := widget.NewToolbar( widget.NewToolbarAction(theme.ContentPasteIcon(), func() { parts.peer.SetText(fyne.CurrentApp().Clipboard().Content()) }), - widget.NewToolbarAction(theme.ContentClearIcon(), func() { parts.peer.SetText("") }), 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, ) - // Primary CTA - encryptBtn := widget.NewButtonWithIcon("Zašifrovat", theme.ConfirmIcon(), func() { encAction(); updateMode() }) + // Š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( @@ -394,7 +416,6 @@ func buildEncryptTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { msgSection := container.NewVBox( widget.NewLabel("Zpráva"), parts.msg, - container.NewHBox(layout.NewSpacer(), encryptBtn), ) outputSection := container.NewVBox( container.NewHBox(widget.NewLabel("Výstup"), layout.NewSpacer(), outputToolbar), @@ -415,16 +436,16 @@ func buildDecryptTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { decryptAction := func() { pl := parts.payload.Text if pl == "" { - parts.showToast("Chybí payload") + parts.plainOut.SetText("") return } go func(js string) { res, err := svc.Decrypt(js) if err != nil { - fyne.Do(func() { parts.plainOut.SetText(""); parts.showToast("Chyba") }) + fyne.Do(func() { parts.plainOut.SetText("") }) return } - fyne.Do(func() { parts.plainOut.SetText(res); parts.showToast("OK") }) + fyne.Do(func() { parts.plainOut.SetText(res) }) }(pl) } updatePayloadQR := func(text string) { @@ -478,7 +499,8 @@ func buildDecryptTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { payloadContainer.Objects = nil if parts.showPayloadQR { updatePayloadQR(parts.payload.Text) - payloadContainer.Add(container.NewVBox(parts.payloadQR, widget.NewButton("Copy", func() { copyClip(parts.payload.Text, parts) }))) + // Jen samotný QR bez spodního copy tlačítka + payloadContainer.Add(parts.payloadQR) payloadToggleAction.SetIcon(theme.VisibilityOffIcon()) } else { payloadContainer.Add(parts.payload) @@ -491,6 +513,7 @@ func buildDecryptTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { if parts.showPayloadQR { updatePayloadQR(parts.payload.Text) } + decryptAction() } updateMode() @@ -499,45 +522,27 @@ func buildDecryptTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { widget.NewToolbarAction(theme.ContentPasteIcon(), func() { clip := fyne.CurrentApp().Clipboard().Content() parts.payload.SetText(clip) - if parts.showPayloadQR { - updatePayloadQR(clip) - } - decryptAction() - }), - widget.NewToolbarAction(theme.ContentClearIcon(), func() { - parts.payload.SetText("") - parts.plainOut.SetText("") - if parts.showPayloadQR { - updatePayloadQR("") - } }), + 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, ) - // Build result toolbar - resultToolbar := widget.NewToolbar( - widget.NewToolbarAction(theme.ContentCopyIcon(), func() { copyClip(parts.plainOut.Text, parts) }), - ) - - // Primary CTA for decryption - decryptBtn := widget.NewButtonWithIcon("Dešifrovat", theme.ConfirmIcon(), func() { decryptAction() }) payloadSection := container.NewVBox( container.NewHBox(widget.NewLabel("Payload"), layout.NewSpacer(), payloadToolbar), payloadContainer, - container.NewHBox(layout.NewSpacer(), decryptBtn), - ) - resultSection := container.NewVBox( - container.NewHBox(widget.NewLabel("Výsledek"), layout.NewSpacer(), resultToolbar), - parts.plainOut, ) + 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}), - payloadSection, - resultSection, + split, ) - return container.NewVScroll(group) + return group } func buildTabbedUI(parts *uiParts, svc ServiceFacade, vaultPath string) fyne.CanvasObject { @@ -548,16 +553,13 @@ func buildTabbedUI(parts *uiParts, svc ServiceFacade, vaultPath string) fyne.Can tabs := container.NewAppTabs(idTab, contactsTab, encTab, decTab) tabs.SetTabLocation(container.TabLocationTop) - // apply fixed dark theme once + // 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() - if prefs != nil { - idx := prefs.IntWithFallback("lastTab", 0) - if idx >= 0 && idx < len(tabs.Items) { - tabs.SelectIndex(idx) - } - // only persist lastTab now - } + // 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()) @@ -592,7 +594,8 @@ func buildContactsTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { // Data + filtering var all []Contact var filtered []Contact - selected := -1 + 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 @@ -613,17 +616,66 @@ func buildContactsTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { filtered = tmp } - // Left list + // Left list (includes optional draft as first row) list := widget.NewList( - func() int { return len(filtered) }, + 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) { - o.(*widget.Label).SetText(filtered[i].Name) + 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() @@ -637,18 +689,64 @@ func buildContactsTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { }), 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() { - if selected < 0 || selected >= len(filtered) { - // new or none selected -> create new - _ = svc.SaveContact(Contact{Name: nameEntry.Text, Cert: certEntry.Text}) - } else { + 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 = nameEntry.Text - c.Cert = certEntry.Text + 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() { @@ -656,14 +754,16 @@ func buildContactsTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { parts.showToast("Kontakt vložen do Šifrování") }) - detail := container.NewBorder( + 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"), - certToolbar, + certRow, ), - container.NewHBox(layout.NewSpacer(), saveBtn, useBtn), + nil, nil, nil, certEntry, ) @@ -671,10 +771,24 @@ func buildContactsTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { // 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) @@ -682,28 +796,40 @@ func buildContactsTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { list.OnUnselected = func(widget.ListItemID) { selected = -1 } // Header: title, search, toolbar - search := widget.NewEntry() - search.SetPlaceHolder("Hledat…") - search.OnChanged = func(s string) { applyFilter(s); list.Refresh() } - addNew := widget.NewToolbarAction(theme.ContentAddIcon(), func() { - nameEntry.SetText("Kontakt") - certEntry.SetText("") + search.OnChanged = func(s string) { + searchQuery = s + applyFilter(s) selected = -1 - }) - addFromClipboard := widget.NewToolbarAction(theme.ContentPasteIcon(), func() { - txt := fyne.CurrentApp().Clipboard().Content() - if txt == "" { - parts.showToast("Schází data v clipboardu") + // 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 } - nm := extractCN(txt) - if nm == "" { - nm = "Kontakt" - } - nameEntry.SetText(nm) - certEntry.SetText(txt) + // 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 { @@ -714,7 +840,7 @@ func buildContactsTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { txt := string(b) nm := extractCN(txt) if nm == "" { - nm = "Kontakt" + nm = "Nový kontakt" } nameEntry.SetText(nm) certEntry.SetText(txt) @@ -722,35 +848,73 @@ func buildContactsTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { }, fyne.CurrentApp().Driver().AllWindows()[0]) fd.Show() }) - deleteAction := widget.NewToolbarAction(theme.DeleteIcon(), func() { - if selected < 0 || selected >= len(filtered) { - return - } - _ = svc.DeleteContact(filtered[selected].ID) - load() - applyFilter(search.Text) - list.Refresh() - nameEntry.SetText("") - certEntry.SetText("") - selected = -1 - }) - copyAction := widget.NewToolbarAction(theme.ContentCopyIcon(), func() { - if selected < 0 || selected >= len(filtered) { - return - } - copyClip(filtered[selected].Cert, parts) - }) - topToolbar := widget.NewToolbar(addNew, addFromClipboard, addFromFile, widget.NewToolbarSeparator(), copyAction, deleteAction) - header := container.NewHBox(widget.NewLabelWithStyle("Kontakty", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), layout.NewSpacer(), search, topToolbar) - // Split view - left := container.NewBorder(nil, nil, nil, nil, list) + // 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) }