Basic GUI client, login dialog, various comments fixes after copypaste.
Some checks failed
Linting and tests / Linting (push) Failing after 6s

This commit is contained in:
Stanislav Nikitin 2025-09-13 09:23:47 +05:00
parent 91bc3acb08
commit 0c37f46b53
Signed by: pztrn
GPG Key ID: 1E944A0F0568B550
24 changed files with 507 additions and 56 deletions

View File

@ -8,6 +8,8 @@ import (
"bunker/client/internal/services/core/mainwindow"
"bunker/client/internal/services/core/options"
"bunker/client/internal/services/core/translations"
"bunker/client/internal/services/features/accounts"
"bunker/client/internal/services/features/tasks"
"bunker/commons"
"fyne.io/fyne/v2"
@ -33,6 +35,9 @@ func main() {
checkError(options.Initialize(app))
checkError(mainwindow.Initialize(app))
checkError(accounts.Initialize(app))
checkError(tasks.Initialize(app))
checkError(app.Start())
}

View File

@ -0,0 +1,13 @@
package helpers
import "runtime"
// IsMobile returns true if current platform related to mobile devices (phones, tablets).
func IsMobile() bool {
switch runtime.GOOS {
case "android", "ios":
return true
default:
return false
}
}

View File

@ -3,8 +3,9 @@ package core
import (
"errors"
"bunker/client/internal/services/core/mainwindow/dto"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
)
// ServiceNameMainWindow is a name for main window service.
@ -20,11 +21,18 @@ var (
// MainWindow is an interface for main window service.
type MainWindow interface {
// AddTab adds tab in main window.
AddTab(tab *container.TabItem)
AddTab(tab *dto.Tab)
// MainWindow returns main window instance (e.g. for using as parent with dialogs).
MainWindow() fyne.Window
// RegisterAboutWindowSysInfoHandler registers handler for System Info tab in About dialog.
RegisterAboutWindowSysInfoHandler(name string, hndl SysInfoHandler) error
// SetStatusProgressBarCurrentValue sets current value for progressbar in status bar.
SetStatusProgressBarCurrentValue(current float64)
// SetStatusProgressBarMaxValue sets maximum value for progressbar in status bar.
SetStatusProgressBarMaxValue(current float64)
// SetStatus sets text in status bar. If non-empty text is passed - then progress bar is also shown, and hidden
// if passed text is empty.
SetStatus(status string)
}
// SysInfoHandler is a function signature for registering with additional system information handler for About dialog.

View File

@ -0,0 +1,20 @@
package dto
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
)
// Tab is a DTO of main window's tab that is responsible for showing content.
type Tab struct {
// Name is a name for tab. Won't render by default on desktop, only on mouse hover, but will be rendered on mobiles.
Name string
// Sidebar is a sidebar widget. On desktop in will be shown on left side of window, on mobiles as separate window.
Sidebar fyne.CanvasObject
// Widget is a widget shown in window.
Widget fyne.CanvasObject
// Icon is an icon to show on tab.
Icon canvas.Image
// BadgeCount is a number to show on tab, like unread messages, incompleted tasks, etc.
BadgeCount uint16
}

View File

@ -6,12 +6,12 @@ import (
"os"
"bunker/client/internal/application"
"bunker/client/internal/helpers"
"bunker/client/internal/services/core"
"bunker/client/internal/services/core/mainwindow/models"
"bunker/commons"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/lang"
"fyne.io/fyne/v2/widget"
)
@ -19,15 +19,18 @@ import (
var _ = core.MainWindow(&mainWindow{})
type mainWindow struct {
app *application.Application
logger *slog.Logger
window fyne.Window
options core.Options
tabs *container.AppTabs
sysInfoHandlers map[string]*models.SysInfoHandler
app *application.Application
logger *slog.Logger
window fyne.Window
options core.Options
tabsWidget *fyne.Container
statusBarProgress *widget.ProgressBar
statusBarStatus *widget.Label
sysInfoHandlers map[string]*models.SysInfoHandler
tabs []*models.Tab
}
// Initialize инициализирует сервис.
// Initialize initializes service.
func Initialize(app *application.Application) error {
mainW := &mainWindow{
app: app,
@ -70,20 +73,20 @@ func (m *mainWindow) Initialize() error {
m.window = m.app.Fyne().NewWindow(lang.L("window.title"))
// ToDo: сохранение и восстановление размеров окна.
//nolint:mnd
m.window.Resize(fyne.NewSize(800, 650))
m.window.Resize(fyne.NewSize(1100, 800))
m.initializeMenu()
var mainWindowCanvas fyne.CanvasObject
if helpers.IsMobile() {
mainWindowCanvas = m.initializeMainWindowMobile()
} else {
mainWindowCanvas = m.initializeMainWindowDesktop()
}
m.window.SetContent(mainWindowCanvas)
m.window.SetCloseIntercept(m.stopApp)
welcomeLabel := widget.NewLabel(lang.L("window.lorem_ipsum.text"))
welcomeLabel.Wrapping = fyne.TextWrapWord
m.tabs = container.NewAppTabs(
container.NewTabItem(lang.L("window.lorem_ipsum.tab_name"), welcomeLabel),
)
m.window.SetContent(m.tabs)
return nil
}

View File

@ -0,0 +1,23 @@
package mainwindow
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
)
func (m *mainWindow) initializeMainWindowDesktop() fyne.CanvasObject {
switcherButton := m.initializeSwitcherDesktop()
appNameLabel := widget.NewLabel("Bunker " + m.app.Fyne().Metadata().Custom["Version"])
sidebarHeader := container.NewVBox(container.NewHBox(switcherButton, appNameLabel), widget.NewSeparator())
sideBar := container.NewBorder(sidebarHeader, nil, nil, nil)
splitter := container.NewHSplit(sideBar, container.NewVBox(widget.NewLabel("widget data"), widget.NewSeparator()))
splitter.SetOffset(0.2)
statusBar := m.initializeDesktopStatusBar()
mainWidget := container.NewBorder(nil, statusBar, nil, nil, splitter)
return mainWidget
}

View File

@ -0,0 +1,42 @@
package mainwindow
import (
"bunker/client/internal/widgets"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/widget"
)
func (m *mainWindow) initializeDesktopStatusBar() fyne.CanvasObject {
m.statusBarStatus = widget.NewLabel("Ready.")
m.statusBarProgress = widget.NewProgressBar()
m.statusBarProgress.Hide()
statusBarForToolbar := widgets.NewToolbarProgressBarWithBar(m.statusBarProgress)
statusBar := widget.NewToolbar()
statusBar.Append(widgets.NewToolbarLabelWithLabel(m.statusBarStatus))
statusBar.Append(statusBarForToolbar)
statusBar.Append(widget.NewToolbarSpacer())
return statusBar
}
func (m *mainWindow) SetStatusProgressBarCurrentValue(current float64) {
m.statusBarProgress.SetValue(current)
}
func (m *mainWindow) SetStatusProgressBarMaxValue(maxValue float64) {
m.statusBarProgress.Max = maxValue
}
func (m *mainWindow) SetStatus(status string) {
m.statusBarStatus.SetText(status)
if status == "" {
m.statusBarStatus.SetText("Ready.")
m.statusBarProgress.Hide()
} else {
m.statusBarProgress.Show()
}
}

View File

@ -0,0 +1,10 @@
package mainwindow
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/widget"
)
func (m *mainWindow) initializeMainWindowMobile() fyne.CanvasObject {
return widget.NewLabel("Mobile interface not yet implemented.")
}

View File

@ -0,0 +1,26 @@
package mainwindow
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
func (m *mainWindow) initializeSwitcherDesktop() fyne.CanvasObject {
m.logger.Debug("Initializing desktop switcher...")
btn := widget.NewButtonWithIcon(
"",
m.app.Fyne().Settings().Theme().Icon(theme.IconNameMenu),
m.desktopSwitcherButtonTapped,
)
return btn
}
func (m *mainWindow) desktopSwitcherButtonTapped() {
m.logger.Debug("Showing desktop switcher...")
popup := widget.NewPopUp(widget.NewLabel("All hail switcher!"), m.window.Canvas())
popup.ShowAtRelativePosition(fyne.NewPos(0, 0), m.window.Content())
}

View File

@ -0,0 +1,5 @@
package mainwindow
func (m *mainWindow) initializeSwitcherMobile() {
m.logger.Debug("Initializing mobile switcher...")
}

View File

@ -0,0 +1,18 @@
package models
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
)
// Tab is an internal representation of main window's tab that is responsible for showing content.
type Tab struct {
// Name is a name for tab. Won't render by default on desktop, only on mouse hover, but will be rendered on mobiles.
Name string
// Widget is a widget shown in window.
Widget fyne.CanvasObject
// Icon is an icon to show on tab.
Icon canvas.Image
// BadgeCount is a number to show on tab, like unread messages, incompleted tasks, etc.
BadgeCount uint16
}

View File

@ -1,14 +1,8 @@
package mainwindow
import (
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/lang"
"bunker/client/internal/services/core/mainwindow/dto"
)
func (m *mainWindow) AddTab(tab *container.TabItem) {
if len(m.tabs.Items) == 1 && m.tabs.Items[0].Text == lang.L("window.lorem_ipsum.tab_name") {
m.tabs.Remove(m.tabs.Items[0])
}
m.tabs.Append(tab)
func (m *mainWindow) AddTab(tab *dto.Tab) {
}

View File

@ -6,20 +6,16 @@ import (
"bunker/client/internal/services/core/options/dto"
)
// ServiceNameOptions это название для сервиса работы с настройками.
// ServiceNameOptions is a name for options service which controls options dialog and options storage.
const ServiceNameOptions = "core/options"
var (
// ErrOptions говорит о возникновении ошибки в сервисе работы с настройками.
ErrOptions = errors.New("options core service")
// ErrOptionsIsInvalid говорит о неверной имплементации сервиса работы с настройками.
ErrOptionsIsInvalid = errors.New("options service implementation is invalid")
)
// ErrOptionsIsInvalid appears when options service implementation is invalid.
var ErrOptionsIsInvalid = errors.New("options service implementation is invalid")
// Options это интерфейс для сервиса работы с настройками.
// Options is an interface for options service.
type Options interface {
// RegisterOptionsWidget регистрирует виджет настроек, а также необходимые дополнительные параметры.
// RegisterOptionsWidget registers options widget for options dialog.
RegisterOptionsWidget(widgetData *dto.OptionPane) error
// ShowOptionsDialog показывает диалог с настройками. Используется только главным окном!
// ShowOptionsDialog shows options dialog.
ShowOptionsDialog()
}

View File

@ -1,6 +1,7 @@
package options
import (
"errors"
"fmt"
"log/slog"
@ -9,7 +10,11 @@ import (
"bunker/client/internal/services/core/options/models"
)
var _ = core.Options(&options{})
var (
_ = core.Options(&options{})
errOptions = errors.New("options core service")
)
type options struct {
app *application.Application
@ -18,17 +23,17 @@ type options struct {
mainWindow core.MainWindow
widgets map[string]*models.OptionPane
widgetsItems []string // для рисования списка Fyne.
widgetsItems []string // for Fyne's list widget.
}
// Initialize инициализирует сервис.
// Initialize initializes service.
func Initialize(app *application.Application) error {
opts := &options{
app: app,
}
if err := app.RegisterService(opts); err != nil {
return fmt.Errorf("%w: %w", core.ErrOptions, err)
return fmt.Errorf("%w: %w", &errOptions, err)
}
return nil

View File

@ -4,7 +4,6 @@ import (
"errors"
"fmt"
"bunker/client/internal/services/core"
"bunker/client/internal/services/core/options/dto"
"bunker/client/internal/services/core/options/models"
)
@ -15,7 +14,7 @@ func (o *options) RegisterOptionsWidget(widgetData *dto.OptionPane) error {
if _, found := o.widgets[widgetData.Name]; found {
return fmt.Errorf(
"%w: RegisterOptionsWidget: '%s': %w",
core.ErrOptions,
errOptions,
widgetData.Name,
errWidgetPaneAlreadyRegistered,
)

View File

@ -4,15 +4,11 @@ import (
"errors"
)
// ServiceNameTranslations это название для сервиса работы с переводами.
// ServiceNameTranslations is a name for translations service.
const ServiceNameTranslations = "core/translations"
var (
// ErrTranslations говорит о возникновении ошибки в сервисе работы с настройками.
ErrTranslations = errors.New("translations core service")
// ErrTranslationsIsInvalid говорит о неверной имплементации сервиса работы с переводами.
ErrTranslationsIsInvalid = errors.New("translations service implementation is invalid")
)
// ErrTranslationsIsInvalid appears when translations service implementation is invalid
var ErrTranslationsIsInvalid = errors.New("translations service implementation is invalid")
// Translations это интерфейс для сервиса работы с переводами.
// Translations is an interface for translations service.
type Translations interface{}

View File

@ -1,6 +1,7 @@
package translations
import (
"errors"
"fmt"
"log/slog"
"os"
@ -12,7 +13,11 @@ import (
"fyne.io/fyne/v2/lang"
)
var _ = core.Translations(&translations{})
var (
_ = core.Translations(&translations{})
errTranslations = errors.New("translations core service")
)
type translations struct {
app *application.Application
@ -20,14 +25,14 @@ type translations struct {
mainWindow core.MainWindow
}
// Initialize инициализирует сервис.
// Initialize initializes service.
func Initialize(app *application.Application) error {
transl := &translations{
app: app,
}
if err := app.RegisterService(transl); err != nil {
return fmt.Errorf("%w: %w", core.ErrOptions, err)
return fmt.Errorf("%w: %w", errTranslations, err)
}
return nil
@ -67,7 +72,7 @@ func (t *translations) Initialize() error {
t.logger.Info("Current system locale.", "locale", lang.SystemLocale().String(), "LANG", langFromEnv)
if err := lang.AddTranslationsFS(langfiles.LangFiles, "files"); err != nil {
return fmt.Errorf("%w: load translations: %w", core.ErrTranslations, err)
return fmt.Errorf("%w: load translations: %w", errTranslations, err)
}
return nil

View File

@ -0,0 +1,4 @@
package features
// ServiceNameAccounts is a name for accounts service.
const ServiceNameAccounts = "features/accounts"

View File

@ -0,0 +1,93 @@
package accounts
import (
"errors"
"fmt"
"log/slog"
"bunker/client/internal/application"
"bunker/client/internal/services/core"
"bunker/client/internal/services/features"
"fyne.io/fyne/v2/widget"
)
var errAccounts = errors.New("accounts feature service")
type accounts struct {
app *application.Application
logger *slog.Logger
db core.Database
mainWindow core.MainWindow
loginDialogInstanceAddressEntry *widget.Entry
loginDialogUsernameEntry *widget.Entry
loginDialogPasswordEntry *widget.Entry
}
// Initialize initializes service.
func Initialize(app *application.Application) error {
accts := &accounts{
app: app,
}
if err := app.RegisterService(accts); err != nil {
return fmt.Errorf("%w: %w", errAccounts, err)
}
return nil
}
func (a *accounts) Configure() error {
return nil
}
func (a *accounts) ConnectDependencies() error {
databaseRaw := a.app.Service(core.ServiceNameDatabase)
if databaseRaw == nil {
return fmt.Errorf("connect dependencies: get database service: %w", application.ErrServiceNotFound)
}
database, valid := databaseRaw.(core.Database)
if !valid {
return fmt.Errorf("connect dependencies: type assert database service: %w", core.ErrDatabaseIsInvalid)
}
a.db = database
mainWindowRaw := a.app.Service(core.ServiceNameMainWindow)
if mainWindowRaw == nil {
return fmt.Errorf("connect dependencies: get main window: %w", application.ErrServiceNotFound)
}
mainWindow, valid := mainWindowRaw.(core.MainWindow)
if !valid {
return fmt.Errorf("connect dependencies: type assert main window: %w", core.ErrMainWindowIsInvalid)
}
a.mainWindow = mainWindow
return nil
}
func (a *accounts) Initialize() error {
a.logger = a.app.NewLogger("service", features.ServiceNameTasks)
a.logger.Info("Initializing...")
return nil
}
func (a *accounts) Name() string {
return features.ServiceNameTasks
}
func (a *accounts) LaunchStartupTasks() error {
a.loginDialogShow()
return nil
}
func (a *accounts) Shutdown() error {
return nil
}

View File

@ -0,0 +1,53 @@
package accounts
import (
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/widget"
)
func (a *accounts) loginDialogLogin() {
a.logger.Info(
"Trying to log in...",
"instance", a.loginDialogInstanceAddressEntry.Text,
"username", a.loginDialogUsernameEntry.Text,
"password", a.loginDialogPasswordEntry.Text,
)
}
func (a *accounts) loginDialogShow() {
if a.loginDialogInstanceAddressEntry == nil {
a.loginDialogInstanceAddressEntry = widget.NewEntry()
a.loginDialogInstanceAddressEntry.SetText("http://localhost:53400")
}
if a.loginDialogUsernameEntry == nil {
a.loginDialogUsernameEntry = widget.NewEntry()
a.loginDialogUsernameEntry.SetPlaceHolder("username")
}
if a.loginDialogPasswordEntry == nil {
a.loginDialogPasswordEntry = widget.NewEntry()
} else {
a.loginDialogPasswordEntry.SetText("")
}
loginForm := widget.NewForm(
widget.NewFormItem("Instance address:", a.loginDialogInstanceAddressEntry),
widget.NewFormItem("Login:", a.loginDialogUsernameEntry),
widget.NewFormItem("Password:", a.loginDialogPasswordEntry),
)
loginButton := widget.NewButton("Log in", a.loginDialogLogin)
loginDialogContent := container.NewBorder(nil, loginButton, nil, nil, loginForm)
dialog := dialog.NewCustomWithoutButtons(
"Login to Bunker instance",
loginDialogContent,
a.mainWindow.MainWindow(),
)
dialog.Resize(dialog.MinSize().AddWidthHeight(200, 0))
dialog.Show()
}

View File

@ -0,0 +1,4 @@
package features
// ServiceNameTasks is a name for tasks service.
const ServiceNameTasks = "features/tasks"

View File

@ -0,0 +1,85 @@
package tasks
import (
"errors"
"fmt"
"log/slog"
"bunker/client/internal/application"
"bunker/client/internal/services/core"
"bunker/client/internal/services/features"
)
var errTasks = errors.New("tasks feature service")
type tasks struct {
app *application.Application
logger *slog.Logger
db core.Database
mainWindow core.MainWindow
}
// Initialize initializes service.
func Initialize(app *application.Application) error {
tsks := &tasks{
app: app,
}
if err := app.RegisterService(tsks); err != nil {
return fmt.Errorf("%w: %w", errTasks, err)
}
return nil
}
func (t *tasks) Configure() error {
return nil
}
func (t *tasks) ConnectDependencies() error {
databaseRaw := t.app.Service(core.ServiceNameDatabase)
if databaseRaw == nil {
return fmt.Errorf("connect dependencies: get database service: %w", application.ErrServiceNotFound)
}
database, valid := databaseRaw.(core.Database)
if !valid {
return fmt.Errorf("connect dependencies: type assert database service: %w", core.ErrDatabaseIsInvalid)
}
t.db = database
mainWindowRaw := t.app.Service(core.ServiceNameMainWindow)
if mainWindowRaw == nil {
return fmt.Errorf("connect dependencies: get main window: %w", application.ErrServiceNotFound)
}
mainWindow, valid := mainWindowRaw.(core.MainWindow)
if !valid {
return fmt.Errorf("connect dependencies: type assert main window: %w", core.ErrMainWindowIsInvalid)
}
t.mainWindow = mainWindow
return nil
}
func (t *tasks) Initialize() error {
t.logger = t.app.NewLogger("service", features.ServiceNameTasks)
t.logger.Info("Initializing...")
return nil
}
func (t *tasks) Name() string {
return features.ServiceNameAccounts
}
func (t *tasks) LaunchStartupTasks() error {
return nil
}
func (t *tasks) Shutdown() error {
return nil
}

View File

@ -0,0 +1,21 @@
package widgets
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/widget"
)
// ToolbarLabel is a label widget for toolbar.
type ToolbarLabel struct {
*widget.Label
}
// NewToolbarLabelWithLabel creates new toolbar label with passed label as base widget.
func NewToolbarLabelWithLabel(label *widget.Label) widget.ToolbarItem {
return &ToolbarLabel{label}
}
// ToolbarObject returns toolbar item.
func (tl *ToolbarLabel) ToolbarObject() fyne.CanvasObject {
return tl.Label
}

View File

@ -0,0 +1,23 @@
package widgets
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/widget"
)
// ToolbarProgressBar is a progressbar widget for toolbar.
type ToolbarProgressBar struct {
*widget.ProgressBar
}
// NewToolbarProgressBarWithBar creates new progressbar for toolbar with provided progressbar.
func NewToolbarProgressBarWithBar(bar *widget.ProgressBar) *ToolbarProgressBar {
return &ToolbarProgressBar{
bar,
}
}
// ToolbarObject returns toolbar item.
func (tl *ToolbarProgressBar) ToolbarObject() fyne.CanvasObject {
return tl
}