From 9ff71823a964c8c60f2abc8092ab399be63867ea Mon Sep 17 00:00:00 2001 From: Lukas Batelka Date: Wed, 24 Sep 2025 22:50:31 +0200 Subject: [PATCH] feature/ui hodne vylepsena sprava kontaktu --- ui.go | 218 +++++++++++++++++++++++++++++++---------------- vault_service.go | 25 +++++- 2 files changed, 168 insertions(+), 75 deletions(-) diff --git a/ui.go b/ui.go index ff25449..616f6f8 100644 --- a/ui.go +++ b/ui.go @@ -6,6 +6,7 @@ import ( "image/color" "io" "os" + "strings" "time" "fyne.io/fyne/v2" @@ -118,9 +119,6 @@ func buildIdentityTab(parts *uiParts, svc ServiceFacade, vaultPath string) fyne. identityToggle := widget.NewToolbarAction(theme.VisibilityOffIcon(), nil) identityToolbar := widget.NewToolbar( widget.NewToolbarAction(theme.ContentCopyIcon(), func() { copyClip(svc.PublicPEM()+"\n"+svc.PublicCert(), parts) }), - widget.NewToolbarAction(theme.ContentPasteIcon(), func() { parts.outKey.SetText(fyne.CurrentApp().Clipboard().Content()) }), - widget.NewToolbarAction(theme.ContentClearIcon(), func() { parts.outKey.SetText("") }), - widget.NewToolbarSeparator(), identityToggle, ) @@ -184,13 +182,25 @@ func buildIdentityTab(parts *uiParts, svc ServiceFacade, vaultPath string) fyne. if parts.showQR { updateQRImages() // Wrap each QR with its copy button - pubBox := container.NewVBox(parts.pubQR, widget.NewButton("Copy", func() { copyClip(svc.PublicPEM(), parts) })) - crtBox := container.NewVBox(parts.crtQR, widget.NewButton("Copy", func() { copyClip(svc.PublicCert(), parts) })) + pubBox := container.NewVBox( + widget.NewLabel("Veřejný klíč (PEM)"), + parts.pubQR, + widget.NewButton("Copy", func() { copyClip(svc.PublicPEM(), parts) }), + ) + crtBox := container.NewVBox( + widget.NewLabel("Certifikát (X.509)"), + parts.crtQR, + widget.NewButton("Copy", 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()) - identityContainer.Add(parts.outKey) + parts.outKey.Disable() + identityContainer.Add(container.NewVBox( + widget.NewLabel("Veřejný klíč + Certifikát (plaintext)"), + parts.outKey, + )) } identityContainer.Refresh() if parts.showQR { @@ -579,64 +589,122 @@ func buttonTile(btns ...fyne.CanvasObject) fyne.CanvasObject { // --- Contacts UI --- func buildContactsTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { + // Data + filtering + var all []Contact + var filtered []Contact + selected := -1 + 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 list := widget.NewList( - func() int { items, _ := svc.ListContacts(); return len(items) }, + func() int { return len(filtered) }, func() fyne.CanvasObject { return widget.NewLabel("") }, func(i widget.ListItemID, o fyne.CanvasObject) { - items, _ := svc.ListContacts() - if i >= 0 && i < len(items) { - o.(*widget.Label).SetText(items[i].Name) + if i >= 0 && int(i) < len(filtered) { + o.(*widget.Label).SetText(filtered[i].Name) } }, ) - var itemsCache []Contact - selected := -1 - refresh := func() { - items, _ := svc.ListContacts() - itemsCache = items - list.Refresh() - } - list.OnSelected = func(id widget.ListItemID) { - selected = int(id) - if id < 0 || id >= len(itemsCache) { - return - } - c := itemsCache[id] - nameEntry := widget.NewEntry() - nameEntry.SetText(c.Name) - certEntry := widget.NewMultiLineEntry() - certEntry.SetMinRowsVisible(6) - certEntry.SetText(c.Cert) - form := widget.NewForm( - widget.NewFormItem("Název", nameEntry), - widget.NewFormItem("Certifikát / Public key", certEntry), - ) - dialog.ShowCustomConfirm("Kontakt", "Uložit", "Zrušit", form, func(ok bool) { - if !ok { - return - } + + // 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("") }), + ) + 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 { + c := filtered[selected] c.Name = nameEntry.Text c.Cert = certEntry.Text _ = svc.SaveContact(c) - refresh() - }, fyne.CurrentApp().Driver().AllWindows()[0]) - } - list.OnUnselected = func(id widget.ListItemID) { selected = -1 } + } + load() + list.Refresh() + 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í") + }) - addFromClipboard := widget.NewButton("Přidat z clipboardu", func() { + detail := container.NewBorder( + container.NewVBox( + widget.NewLabel("Název"), + nameEntry, + widget.NewLabel("Certifikát / Public key"), + certToolbar, + ), + container.NewHBox(layout.NewSpacer(), saveBtn, useBtn), + nil, nil, + certEntry, + ) + + // List selection behavior + list.OnSelected = func(id widget.ListItemID) { + idx := int(id) + if idx < 0 || idx >= len(filtered) { + return + } + selected = idx + c := filtered[idx] + nameEntry.SetText(c.Name) + certEntry.SetText(c.Cert) + } + 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("") + selected = -1 + }) + addFromClipboard := widget.NewToolbarAction(theme.ContentPasteIcon(), func() { txt := fyne.CurrentApp().Clipboard().Content() if txt == "" { parts.showToast("Schází data v clipboardu") return } - name := extractCN(txt) - if name == "" { - name = "Kontakt" + nm := extractCN(txt) + if nm == "" { + nm = "Kontakt" } - _ = svc.SaveContact(Contact{Name: name, Cert: txt}) - refresh() + nameEntry.SetText(nm) + certEntry.SetText(txt) + selected = -1 }) - addFromFile := widget.NewButton("Přidat ze souboru", func() { + addFromFile := widget.NewToolbarAction(theme.FolderOpenIcon(), func() { fd := dialog.NewFileOpen(func(r fyne.URIReadCloser, err error) { if err != nil || r == nil { return @@ -644,41 +712,45 @@ func buildContactsTab(parts *uiParts, svc ServiceFacade) fyne.CanvasObject { defer r.Close() b, _ := io.ReadAll(r) txt := string(b) - name := extractCN(txt) - if name == "" { - name = "Kontakt" + nm := extractCN(txt) + if nm == "" { + nm = "Kontakt" } - _ = svc.SaveContact(Contact{Name: name, Cert: txt}) - refresh() + nameEntry.SetText(nm) + certEntry.SetText(txt) + selected = -1 }, fyne.CurrentApp().Driver().AllWindows()[0]) fd.Show() }) - deleteBtn := widget.NewButton("Smazat", func() { - sel := selected - if sel < 0 || sel >= len(itemsCache) { + deleteAction := widget.NewToolbarAction(theme.DeleteIcon(), func() { + if selected < 0 || selected >= len(filtered) { return } - _ = svc.DeleteContact(itemsCache[sel].ID) - refresh() + _ = svc.DeleteContact(filtered[selected].ID) + load() + applyFilter(search.Text) + list.Refresh() + nameEntry.SetText("") + certEntry.SetText("") + selected = -1 }) - copyBtn := widget.NewButton("Zkopírovat cert", func() { - sel := selected - if sel < 0 || sel >= len(itemsCache) { + copyAction := widget.NewToolbarAction(theme.ContentCopyIcon(), func() { + if selected < 0 || selected >= len(filtered) { return } - copyClip(itemsCache[sel].Cert, parts) - }) - useBtn := widget.NewButton("Použít ve Šifrování", func() { - sel := selected - if sel < 0 || sel >= len(itemsCache) { - return - } - parts.peer.SetText(itemsCache[sel].Cert) - parts.showToast("Kontakt vložen do Šifrování") + 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) - header := container.NewHBox(addFromClipboard, addFromFile, deleteBtn, copyBtn, useBtn) - content := container.NewBorder(header, nil, nil, nil, list) - refresh() - return content + // Split view + left := container.NewBorder(nil, nil, nil, nil, list) + right := container.NewStack(detail) + split := container.NewHSplit(left, right) + split.SetOffset(0.35) + + // Initialize + load() + list.Refresh() + return container.NewBorder(header, nil, nil, nil, split) } diff --git a/vault_service.go b/vault_service.go index ccbc358..c11a3b1 100644 --- a/vault_service.go +++ b/vault_service.go @@ -13,6 +13,7 @@ import ( "errors" encrypt "fckeuspy-go/lib" "fmt" + mrand "math/rand" ) // VaultService implementuje ServiceFacade nad SecureJSONStore. @@ -100,9 +101,9 @@ func (v *VaultService) ListContacts() ([]Contact, error) { func (v *VaultService) SaveContact(c Contact) error { list, _ := v.ListContacts() - // upsert by ID, if empty ID derive from CN or hash + // upsert by ID; if empty ID, assign a new unique random ID to avoid overwriting if c.ID == "" { - c.ID = deriveContactID(c) + c.ID = generateUniqueContactID(list) } replaced := false for i := range list { @@ -144,6 +145,26 @@ func deriveContactID(c Contact) string { return fmt.Sprintf("%x", sum[:8]) } +// generateUniqueContactID creates a short random hex ID that does not collide with existing list. +func generateUniqueContactID(existing []Contact) string { + exists := make(map[string]struct{}, len(existing)) + for _, c := range existing { + exists[c.ID] = struct{}{} + } + for { + b := make([]byte, 6) + if _, err := rand.Read(b); err != nil { + // fallback to time-based seed rand + n := mrand.Int63() + return fmt.Sprintf("%x", n) + } + id := fmt.Sprintf("%x", b) + if _, ok := exists[id]; !ok { + return id + } + } +} + func extractCN(pemText string) string { block, _ := pem.Decode([]byte(pemText)) if block == nil {