955 lines
28 KiB
Go
955 lines
28 KiB
Go
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í…")
|
||
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
|
||
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 toggle (QR vs text)
|
||
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()
|
||
}
|
||
updateOutput := func() {
|
||
outputContainer.Objects = nil
|
||
if parts.showQR { // show QR mode
|
||
updateQR(parts.cipherOut.Text)
|
||
outputContainer.Add(parts.cipherQR)
|
||
outputToggleAction.SetIcon(theme.VisibilityOffIcon())
|
||
} else {
|
||
outputContainer.Add(parts.cipherOut)
|
||
outputToggleAction.SetIcon(theme.VisibilityIcon())
|
||
}
|
||
outputContainer.Refresh()
|
||
}
|
||
outputToggleAction.OnActivated = func() { parts.showQR = !parts.showQR; updateOutput() }
|
||
updateOutput()
|
||
|
||
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)
|
||
// refresh whichever mode is active
|
||
updateOutput()
|
||
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,
|
||
)
|
||
// Split: message (top) vs output (bottom) for better prostor "do spodu"
|
||
split := container.NewVSplit(msgSection, outputSection)
|
||
split.SetOffset(0.40)
|
||
group := container.NewVBox(
|
||
widget.NewLabelWithStyle("Šifrování", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||
peerSection,
|
||
split,
|
||
)
|
||
return 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
|
||
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
|
||
}
|
||
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("")
|
||
// 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)
|
||
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 {
|
||
// 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)
|
||
}
|