From a9d2b95886d48a1509546500c451d13cf24c369d Mon Sep 17 00:00:00 2001 From: Lain Iwakura Date: Tue, 5 Aug 2025 20:08:15 +0300 Subject: [PATCH] v2 (fckn yeaaah) --- README.md | 43 ++++++-- api/client.go | 46 +++++++++ cache/cache.go | 203 +++++++++++++++++++++++++++++++++++++ main.go | 5 +- mobile_optimizations.go | 122 ++++++++++++++++++++++ models/models.go | 2 - settings/settings.go | 159 +++++++++++++++++++++++++++++ ui/boards_screen.go | 1 + ui/manager.go | 108 +++++++++++++++----- ui/optimization.go | 179 ++++++++++++++++++++++++++++++++ ui/settings_screen.go | 186 +++++++++++++++++++++++++++++++++ ui/thread_detail_screen.go | 7 +- ui/threads_screen.go | 93 ++++++++++++++--- 13 files changed, 1099 insertions(+), 55 deletions(-) create mode 100644 cache/cache.go create mode 100644 mobile_optimizations.go create mode 100644 settings/settings.go create mode 100644 ui/optimization.go create mode 100644 ui/settings_screen.go diff --git a/README.md b/README.md index 5af212a..fe16fca 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,35 @@ ## Возможности -- Просмотр досок и тредов -- Чтение комментариев -- Темная/светлая тема -- Навигация между экранами +- Просмотр всех досок Мкача +- Просмотр тредов в каждой доске +- Просмотр деталей треда и комментариев - Поддержка изображений и видео -- Адаптивный интерфейс +- Темная/светлая тема +- Навигация с кнопкой "Назад" (index lore) +- Улучшенный заголовок с статическими кнопками +- Система настроек с сохранением: + - Тема (темная/светлая) + - Последняя посещенная доска + - Автообновление + - Показ файлов + - Компактный режим +- Поддержка Android и iOS (с сохранением настроек (блять нахуй я туда полез)) +- **Оптимизации для мобильных устройств:** + - Кэширование данных для быстрой загрузки + - Дебаунсинг UI обновлений + - Оптимизация потребления батареи + - Агрессивная сборка мусора на мобильных + - Ограничение частоты сетевых запросов + - **Многостраничность для списка тредов:** + - Настраиваемый размер страницы (5-20 тредов) + - Навигация между страницами + - Ограничение тредов для больших досок + - **Управление кэшем:** + - Очистка кэша досок + - Очистка кэша тредов для всех досок + - Очистка кэша деталей тредов + - Очистка всего кэша ## Сборка @@ -72,7 +95,8 @@ open MobileMkch.xcodeproj 5. Подпишите и установите через Xcode или TestFlight -**✅ iOS сборка протестирована и работает!** +**✅ iOS И Android сборка протестирована и работает!** + ## Требования @@ -92,8 +116,13 @@ open MobileMkch.xcodeproj - `main.go` - точка входа - `api/client.go` - HTTP клиент для mkch API - `models/models.go` - структуры данных +- `settings/settings.go` - система настроек +- `cache/cache.go` - система кэширования с поддержкой пагинации - `ui/` - пользовательский интерфейс - `manager.go` - управление экранами - `boards_screen.go` - список досок - `threads_screen.go` - треды доски - - `thread_detail_screen.go` - детали треда \ No newline at end of file + - `thread_detail_screen.go` - детали треда + - `settings_screen.go` - экран настроек + - `optimization.go` - утилиты оптимизации +- `mobile_optimizations.go` - оптимизации для мобильных устройств \ No newline at end of file diff --git a/api/client.go b/api/client.go index d34959b..30bc775 100644 --- a/api/client.go +++ b/api/client.go @@ -7,6 +7,7 @@ import ( "net/http" "time" + "MobileMkch/cache" "MobileMkch/models" ) @@ -69,6 +70,14 @@ func (c *Client) makeRequest(url string) ([]byte, error) { } func (c *Client) GetBoards() ([]models.Board, error) { + // Поздравляю, кеш + if boards, exists := cache.GetCache().GetBoards(); exists { + if c.debug { + fmt.Printf("[API] Доски загружены из кэша\n") + } + return boards, nil + } + url := c.baseURL + "/boards/" body, err := c.makeRequest(url) if err != nil { @@ -84,6 +93,9 @@ func (c *Client) GetBoards() ([]models.Board, error) { return nil, fmt.Errorf("ошибка парсинга досок: %w", err) } + // Всех победили + cache.GetCache().SetBoards(boards) + if c.debug { fmt.Printf("[API] Получено досок: %d\n", len(boards)) } @@ -92,6 +104,13 @@ func (c *Client) GetBoards() ([]models.Board, error) { } func (c *Client) GetThreads(boardCode string) ([]models.Thread, error) { + if threads, exists := cache.GetCache().GetThreads(boardCode); exists { + if c.debug { + fmt.Printf("[API] Треды загружены из кэша для /%s/\n", boardCode) + } + return threads, nil + } + url := fmt.Sprintf("%s/board/%s", c.baseURL, boardCode) body, err := c.makeRequest(url) if err != nil { @@ -107,6 +126,15 @@ func (c *Client) GetThreads(boardCode string) ([]models.Thread, error) { return nil, fmt.Errorf("ошибка парсинга тредов: %w", err) } + // if len(threads) > 12 { + // if c.debug { + // fmt.Printf("[API] Ограничиваем количество тредов с %d до 5\n", len(threads)) + // } + // threads = threads[:12] + // } + + cache.GetCache().SetThreads(boardCode, threads) + if c.debug { fmt.Printf("[API] Получено тредов в /%s/: %d\n", boardCode, len(threads)) } @@ -115,6 +143,13 @@ func (c *Client) GetThreads(boardCode string) ([]models.Thread, error) { } func (c *Client) GetThread(boardCode string, threadID int) (*models.ThreadDetail, error) { + if thread, exists := cache.GetCache().GetThreadDetail(threadID); exists { + if c.debug { + fmt.Printf("[API] Тред %d загружен из кэша\n", threadID) + } + return thread, nil + } + url := fmt.Sprintf("%s/board/%s/thread/%d", c.baseURL, boardCode, threadID) body, err := c.makeRequest(url) if err != nil { @@ -130,6 +165,8 @@ func (c *Client) GetThread(boardCode string, threadID int) (*models.ThreadDetail return nil, fmt.Errorf("ошибка парсинга треда: %w", err) } + cache.GetCache().SetThreadDetail(threadID, &thread) + if c.debug { fmt.Printf("[API] Получен тред: ID=%d, Title=%s\n", thread.ID, thread.Title) } @@ -138,6 +175,13 @@ func (c *Client) GetThread(boardCode string, threadID int) (*models.ThreadDetail } func (c *Client) GetComments(boardCode string, threadID int) ([]models.Comment, error) { + if comments, exists := cache.GetCache().GetComments(threadID); exists { + if c.debug { + fmt.Printf("[API] Комментарии загружены из кэша для треда %d\n", threadID) + } + return comments, nil + } + url := fmt.Sprintf("%s/board/%s/thread/%d/comments", c.baseURL, boardCode, threadID) body, err := c.makeRequest(url) if err != nil { @@ -153,6 +197,8 @@ func (c *Client) GetComments(boardCode string, threadID int) ([]models.Comment, return nil, fmt.Errorf("ошибка парсинга комментариев: %w", err) } + cache.GetCache().SetComments(threadID, comments) + if c.debug { fmt.Printf("[API] Получено комментариев: %d\n", len(comments)) } diff --git a/cache/cache.go b/cache/cache.go new file mode 100644 index 0000000..9b02fc3 --- /dev/null +++ b/cache/cache.go @@ -0,0 +1,203 @@ +package cache + +import ( + "encoding/json" + "fmt" + "log" + "sync" + "time" + + "MobileMkch/models" +) + +type Cache struct { + mu sync.RWMutex + items map[string]cacheItem +} + +type cacheItem struct { + Data interface{} + Timestamp time.Time + TTL time.Duration +} + +var ( + instance *Cache + once sync.Once +) + +func GetCache() *Cache { + once.Do(func() { + instance = &Cache{ + items: make(map[string]cacheItem), + } + go instance.cleanup() + }) + return instance +} + +func (c *Cache) Set(key string, data interface{}, ttl time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + + c.items[key] = cacheItem{ + Data: data, + Timestamp: time.Now(), + TTL: ttl, + } + + log.Printf("Кэшировано: %s (TTL: %v)", key, ttl) +} + +func (c *Cache) Get(key string) (interface{}, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + + item, exists := c.items[key] + if !exists { + return nil, false + } + + if time.Since(item.Timestamp) > item.TTL { + log.Printf("Кэш устарел: %s", key) + return nil, false + } + + log.Printf("Кэш найден: %s", key) + return item.Data, true +} + +func (c *Cache) Delete(key string) { + c.mu.Lock() + defer c.mu.Unlock() + + delete(c.items, key) + log.Printf("Кэш удален: %s", key) +} + +func (c *Cache) Clear() { + c.mu.Lock() + defer c.mu.Unlock() + + c.items = make(map[string]cacheItem) + log.Printf("Кэш очищен") +} + +func (c *Cache) cleanup() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + c.mu.Lock() + now := time.Now() + for key, item := range c.items { + if now.Sub(item.Timestamp) > item.TTL { + delete(c.items, key) + log.Printf("Автоочистка кэша: %s", key) + } + } + c.mu.Unlock() + } +} + +func (c *Cache) SetBoards(boards []models.Board) { + data, _ := json.Marshal(boards) + c.Set("boards", data, 10*time.Minute) +} + +func (c *Cache) GetBoards() ([]models.Board, bool) { + data, exists := c.Get("boards") + if !exists { + return nil, false + } + + var boards []models.Board + if err := json.Unmarshal(data.([]byte), &boards); err != nil { + log.Printf("Ошибка десериализации досок: %v", err) + return nil, false + } + + return boards, true +} + +func (c *Cache) SetThreads(boardCode string, threads []models.Thread) { + data, _ := json.Marshal(threads) + c.Set("threads_"+boardCode, data, 5*time.Minute) +} + +func (c *Cache) GetThreads(boardCode string) ([]models.Thread, bool) { + data, exists := c.Get("threads_" + boardCode) + if !exists { + return nil, false + } + + var threads []models.Thread + if err := json.Unmarshal(data.([]byte), &threads); err != nil { + log.Printf("Ошибка десериализации тредов: %v", err) + return nil, false + } + + return threads, true +} + +func (c *Cache) SetThreadsPage(boardCode string, page int, threads []models.Thread) { + data, _ := json.Marshal(threads) + key := fmt.Sprintf("threads_%s_page_%d", boardCode, page) + c.Set(key, data, 3*time.Minute) +} + +func (c *Cache) GetThreadsPage(boardCode string, page int) ([]models.Thread, bool) { + key := fmt.Sprintf("threads_%s_page_%d", boardCode, page) + data, exists := c.Get(key) + if !exists { + return nil, false + } + + var threads []models.Thread + if err := json.Unmarshal(data.([]byte), &threads); err != nil { + log.Printf("Ошибка десериализации тредов страницы: %v", err) + return nil, false + } + + return threads, true +} + +func (c *Cache) SetThreadDetail(threadID int, thread *models.ThreadDetail) { + data, _ := json.Marshal(thread) + c.Set("thread_"+string(rune(threadID)), data, 3*time.Minute) +} + +func (c *Cache) GetThreadDetail(threadID int) (*models.ThreadDetail, bool) { + data, exists := c.Get("thread_" + string(rune(threadID))) + if !exists { + return nil, false + } + + var thread models.ThreadDetail + if err := json.Unmarshal(data.([]byte), &thread); err != nil { + log.Printf("Ошибка десериализации треда: %v", err) + return nil, false + } + + return &thread, true +} + +func (c *Cache) SetComments(threadID int, comments []models.Comment) { + data, _ := json.Marshal(comments) + c.Set("comments_"+string(rune(threadID)), data, 3*time.Minute) +} + +func (c *Cache) GetComments(threadID int) ([]models.Comment, bool) { + data, exists := c.Get("comments_" + string(rune(threadID))) + if !exists { + return nil, false + } + + var comments []models.Comment + if err := json.Unmarshal(data.([]byte), &comments); err != nil { + log.Printf("Ошибка десериализации комментариев: %v", err) + return nil, false + } + + return comments, true +} diff --git a/main.go b/main.go index 9dff402..e915c52 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "fyne.io/fyne/v2/widget" "MobileMkch/api" + "MobileMkch/settings" "MobileMkch/ui" ) @@ -17,7 +18,9 @@ func main() { a := app.NewWithID("com.mkch.mobile") a.SetIcon(theme.ComputerIcon()) - a.Settings().SetTheme(theme.DarkTheme()) + settings.SetApp(a) + + MobileOptimizations(a) w := a.NewWindow("MobileMkch") w.Resize(fyne.NewSize(400, 700)) diff --git a/mobile_optimizations.go b/mobile_optimizations.go new file mode 100644 index 0000000..854359e --- /dev/null +++ b/mobile_optimizations.go @@ -0,0 +1,122 @@ +package main + +import ( + "runtime" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/theme" +) + +func MobileOptimizations(a fyne.App) { + if runtime.GOOS == "android" || runtime.GOOS == "ios" { + settings := a.Settings() + settings.SetTheme(theme.DarkTheme()) + + if runtime.GOOS == "android" { + time.Sleep(100 * time.Millisecond) + } + + if runtime.GOOS == "ios" { + time.Sleep(50 * time.Millisecond) + } + } +} + +type PerformanceMonitor struct { + startTime time.Time + metrics map[string]time.Duration +} + +func NewPerformanceMonitor() *PerformanceMonitor { + return &PerformanceMonitor{ + startTime: time.Now(), + metrics: make(map[string]time.Duration), + } +} + +func (pm *PerformanceMonitor) StartTimer(name string) { + pm.startTime = time.Now() +} + +func (pm *PerformanceMonitor) EndTimer(name string) { + duration := time.Since(pm.startTime) + pm.metrics[name] = duration + + if duration > 100*time.Millisecond { + println("Медленная операция:", name, duration) + } +} + +func (pm *PerformanceMonitor) GetMetrics() map[string]time.Duration { + return pm.metrics +} + +type MemoryOptimizer struct { + lastGC time.Time +} + +func NewMemoryOptimizer() *MemoryOptimizer { + return &MemoryOptimizer{ + lastGC: time.Now(), + } +} + +func (mo *MemoryOptimizer) CheckMemory() { + if time.Since(mo.lastGC) > 30*time.Second { + if runtime.GOOS == "android" || runtime.GOOS == "ios" { + runtime.GC() + mo.lastGC = time.Now() + } + } +} + +type BatteryOptimizer struct { + lastUpdate time.Time + updateInterval time.Duration +} + +func NewBatteryOptimizer() *BatteryOptimizer { + interval := 5 * time.Second + if runtime.GOOS == "android" || runtime.GOOS == "ios" { + interval = 10 * time.Second + } + + return &BatteryOptimizer{ + lastUpdate: time.Now(), + updateInterval: interval, + } +} + +func (bo *BatteryOptimizer) ShouldUpdate() bool { + return time.Since(bo.lastUpdate) >= bo.updateInterval +} + +func (bo *BatteryOptimizer) UpdateComplete() { + bo.lastUpdate = time.Now() +} + +type NetworkOptimizer struct { + lastRequest time.Time + minInterval time.Duration +} + +func NewNetworkOptimizer() *NetworkOptimizer { + interval := 1 * time.Second + if runtime.GOOS == "android" || runtime.GOOS == "ios" { + interval = 2 * time.Second + } + + return &NetworkOptimizer{ + lastRequest: time.Now(), + minInterval: interval, + } +} + +func (no *NetworkOptimizer) CanMakeRequest() bool { + return time.Since(no.lastRequest) >= no.minInterval +} + +func (no *NetworkOptimizer) RequestComplete() { + no.lastRequest = time.Now() +} diff --git a/models/models.go b/models/models.go index c227329..778bf60 100644 --- a/models/models.go +++ b/models/models.go @@ -15,7 +15,6 @@ type Thread struct { Title string `json:"title"` Text string `json:"text"` Creation string `json:"creation"` - Author string `json:"author"` Board string `json:"board"` Rating *int `json:"rating,omitempty"` Pinned *bool `json:"pinned,omitempty"` @@ -45,7 +44,6 @@ type ThreadDetail struct { Creation string `json:"creation"` Title string `json:"title"` Text string `json:"text"` - Author string `json:"author"` Board string `json:"board"` Files []string `json:"files"` } diff --git a/settings/settings.go b/settings/settings.go new file mode 100644 index 0000000..e83debe --- /dev/null +++ b/settings/settings.go @@ -0,0 +1,159 @@ +package settings + +import ( + "encoding/json" + "io" + "log" + "os" + "path/filepath" + "runtime" + + "fyne.io/fyne/v2" +) + +type Settings struct { + Theme string `json:"theme"` + LastBoard string `json:"last_board"` + AutoRefresh bool `json:"auto_refresh"` + ShowFiles bool `json:"show_files"` + CompactMode bool `json:"compact_mode"` + PageSize int `json:"page_size"` +} + +var settingsPath string +var fyneApp fyne.App + +func SetApp(app fyne.App) { + fyneApp = app +} + +func Init() error { + var appDir string + + if runtime.GOOS == "android" || runtime.GOOS == "ios" { + appDir = filepath.Join(os.TempDir(), "mobilemkch") + log.Printf("Используем временную директорию для %s: %s", runtime.GOOS, appDir) + } else { + homeDir, err := os.UserHomeDir() + if err != nil { + return err + } + appDir = filepath.Join(homeDir, ".mobilemkch") + log.Printf("Используем домашнюю директорию: %s", appDir) + } + + if err := os.MkdirAll(appDir, 0755); err != nil { + return err + } + + settingsPath = filepath.Join(appDir, "settings.json") + log.Printf("Путь к настройкам: %s", settingsPath) + return nil +} + +func Load() (*Settings, error) { + settings := &Settings{ + Theme: "dark", + LastBoard: "", + AutoRefresh: true, + ShowFiles: true, + CompactMode: false, + PageSize: 10, + } + + if (runtime.GOOS == "android" || runtime.GOOS == "ios") && fyneApp != nil { + log.Printf("Загружаем настройки через Fyne Storage") + storage := fyneApp.Storage() + + reader, err := storage.Open("settings.json") + if err != nil { + log.Printf("Файл настроек не найден в Fyne Storage, создаем новый") + return settings, Save(settings) + } + defer reader.Close() + + data, err := io.ReadAll(reader) + if err != nil { + log.Printf("Ошибка чтения из Fyne Storage: %v", err) + return nil, err + } + + if err := json.Unmarshal(data, settings); err != nil { + log.Printf("Ошибка парсинга настроек из Fyne Storage: %v", err) + return nil, err + } + + log.Printf("Настройки загружены из Fyne Storage") + return settings, nil + } + + if settingsPath == "" { + if err := Init(); err != nil { + return nil, err + } + } + + log.Printf("Загружаем настройки из: %s", settingsPath) + data, err := os.ReadFile(settingsPath) + if err != nil { + if os.IsNotExist(err) { + log.Printf("Файл настроек не найден, создаем новый") + return settings, Save(settings) + } + log.Printf("Ошибка чтения настроек: %v", err) + return nil, err + } + + if err := json.Unmarshal(data, settings); err != nil { + log.Printf("Ошибка парсинга настроек: %v", err) + return nil, err + } + + log.Printf("Настройки загружены успешно") + return settings, nil +} + +func Save(settings *Settings) error { + if (runtime.GOOS == "android" || runtime.GOOS == "ios") && fyneApp != nil { + log.Printf("Сохраняем настройки через Fyne Storage") + storage := fyneApp.Storage() + + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + log.Printf("Ошибка сериализации настроек: %v", err) + return err + } + + writer, err := storage.Create("settings.json") + if err != nil { + log.Printf("Ошибка создания файла в Fyne Storage: %v", err) + return err + } + defer writer.Close() + + _, err = writer.Write(data) + if err != nil { + log.Printf("Ошибка сохранения через Fyne Storage: %v", err) + return err + } + + log.Printf("Настройки сохранены через Fyne Storage") + return nil + } + + log.Printf("Сохраняем настройки в: %s", settingsPath) + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + log.Printf("Ошибка сериализации настроек: %v", err) + return err + } + + err = os.WriteFile(settingsPath, data, 0644) + if err != nil { + log.Printf("Ошибка записи настроек: %v", err) + return err + } + + log.Printf("Настройки сохранены успешно") + return nil +} diff --git a/ui/boards_screen.go b/ui/boards_screen.go index 2539c77..42b655d 100644 --- a/ui/boards_screen.go +++ b/ui/boards_screen.go @@ -150,6 +150,7 @@ func (bs *BoardsScreen) createBoardCard(board *models.Board) fyne.CanvasObject { } button := widget.NewButton(fmt.Sprintf("%s\n%s", title, description), func() { + bs.uiManager.SetLastBoard(board.Code) bs.uiManager.ShowBoardThreads(board) }) diff --git a/ui/manager.go b/ui/manager.go index 39bbcd0..baad658 100644 --- a/ui/manager.go +++ b/ui/manager.go @@ -1,13 +1,14 @@ package ui import ( + "MobileMkch/api" + "MobileMkch/models" + "MobileMkch/settings" + "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" - - "MobileMkch/api" - "MobileMkch/models" ) type UIManager struct { @@ -22,7 +23,7 @@ type UIManager struct { headerBar *fyne.Container content *fyne.Container - isDarkTheme bool + settings *settings.Settings } type Screen interface { @@ -33,13 +34,30 @@ type Screen interface { } func NewUIManager(app fyne.App, window fyne.Window, apiClient *api.Client) *UIManager { + appSettings, err := settings.Load() + if err != nil { + appSettings = &settings.Settings{ + Theme: "dark", + LastBoard: "", + AutoRefresh: true, + ShowFiles: true, + CompactMode: false, + } + } + manager := &UIManager{ app: app, window: window, apiClient: apiClient, navigationStack: make([]Screen, 0), content: container.NewMax(), - isDarkTheme: true, // По умолчанию темная тема + settings: appSettings, + } + + if appSettings.Theme == "dark" { + app.Settings().SetTheme(theme.DarkTheme()) + } else { + app.Settings().SetTheme(theme.LightTheme()) } manager.setupHeader() @@ -52,30 +70,30 @@ func NewUIManager(app fyne.App, window fyne.Window, apiClient *api.Client) *UIMa } func (ui *UIManager) setupHeader() { - titleLabel := widget.NewLabel("MobileMkch") - titleLabel.TextStyle = fyne.TextStyle{Bold: true} - - backButton := widget.NewButton("← Назад", func() { + backButton := widget.NewButton("<- Назад", func() { ui.GoBack() }) backButton.Hide() + titleLabel := widget.NewLabel("MobileMkch") + titleLabel.TextStyle = fyne.TextStyle{Bold: true} + titleLabel.Alignment = fyne.TextAlignCenter + refreshButton := widget.NewButton("🔄", func() { if ui.currentScreen != nil { ui.currentScreen.OnShow() } }) - themeButton := widget.NewButton("🌙", func() { - ui.toggleTheme() + settingsButton := widget.NewButton("⚙️", func() { + settingsScreen := NewSettingsScreen(ui) + ui.ShowScreen(settingsScreen) }) - rightButtons := container.NewHBox(refreshButton, themeButton) + rightButtons := container.NewHBox(refreshButton, settingsButton) - ui.headerBar = container.NewHBox( - backButton, - titleLabel, - rightButtons, + ui.headerBar = container.NewBorder( + nil, nil, backButton, rightButtons, titleLabel, ) } @@ -100,12 +118,10 @@ func (ui *UIManager) ShowScreen(screen Screen) { ui.navigationStack = append(ui.navigationStack, screen) ui.currentScreen = screen - ui.content.RemoveAll() ui.content.Add(screen.GetContent()) - ui.content.Refresh() - ui.updateHeader(screen.GetTitle()) + ui.content.Refresh() screen.OnShow() } @@ -132,22 +148,23 @@ func (ui *UIManager) GoBack() { } func (ui *UIManager) toggleTheme() { - ui.isDarkTheme = !ui.isDarkTheme - - if ui.isDarkTheme { - ui.app.Settings().SetTheme(theme.DarkTheme()) - } else { + if ui.settings.Theme == "dark" { + ui.settings.Theme = "light" ui.app.Settings().SetTheme(theme.LightTheme()) + } else { + ui.settings.Theme = "dark" + ui.app.Settings().SetTheme(theme.DarkTheme()) } + settings.Save(ui.settings) ui.window.Canvas().Refresh(ui.window.Canvas().Content()) } func (ui *UIManager) updateHeader(title string) { ui.window.SetTitle(title) - if len(ui.headerBar.Objects) > 0 { - if backBtn, ok := ui.headerBar.Objects[0].(*widget.Button); ok { + if len(ui.headerBar.Objects) >= 3 { + if backBtn, ok := ui.headerBar.Objects[1].(*widget.Button); ok { if len(ui.navigationStack) > 1 { backBtn.Show() } else { @@ -208,3 +225,42 @@ func (ui *UIManager) ShowInfo(title, message string) { ) dialog.Show() } + +func (ui *UIManager) GetSettings() *settings.Settings { + return ui.settings +} + +func (ui *UIManager) SaveSettings() error { + return settings.Save(ui.settings) +} + +func (ui *UIManager) SetLastBoard(boardCode string) { + ui.settings.LastBoard = boardCode + ui.SaveSettings() +} + +func (ui *UIManager) GetLastBoard() string { + return ui.settings.LastBoard +} + +func (ui *UIManager) ShowAbout() { + aboutWindow := ui.app.NewWindow("Об аппке") + aboutWindow.Resize(fyne.NewSize(400, 300)) + + content := container.NewVBox( + widget.NewLabel("MobileMkch"), + widget.NewLabel("Мобильный клиент для мкача"), + widget.NewSeparator(), + widget.NewLabel("Версия: 2.0.0-alpha (Always in alpha lol)"), + widget.NewLabel("Автор: w^x (лейн, платон, а похуй как угодно)"), + widget.NewLabel("Разработано с ❤️ на Go + Fyne"), + ) + + closeButton := widget.NewButton("Закрыть", func() { + aboutWindow.Close() + }) + content.Add(closeButton) + + aboutWindow.SetContent(content) + aboutWindow.Show() +} diff --git a/ui/optimization.go b/ui/optimization.go new file mode 100644 index 0000000..12bfe9e --- /dev/null +++ b/ui/optimization.go @@ -0,0 +1,179 @@ +package ui + +import ( + "sync" + "time" + + "fyne.io/fyne/v2" +) + +type Debouncer struct { + mu sync.Mutex + timer *time.Timer + duration time.Duration + callback func() +} + +func NewDebouncer(duration time.Duration, callback func()) *Debouncer { + return &Debouncer{ + duration: duration, + callback: callback, + } +} + +func (d *Debouncer) Trigger() { + d.mu.Lock() + defer d.mu.Unlock() + + if d.timer != nil { + d.timer.Stop() + } + + d.timer = time.AfterFunc(d.duration, func() { + fyne.Do(d.callback) + }) +} + +type Throttler struct { + mu sync.Mutex + lastCall time.Time + interval time.Duration + callback func() +} + +func NewThrottler(interval time.Duration, callback func()) *Throttler { + return &Throttler{ + interval: interval, + callback: callback, + } +} + +func (t *Throttler) Trigger() { + t.mu.Lock() + defer t.mu.Unlock() + + now := time.Now() + if now.Sub(t.lastCall) >= t.interval { + t.lastCall = now + fyne.Do(t.callback) + } +} + +type LazyLoader struct { + mu sync.Mutex + loaded bool + loading bool + callback func() +} + +func NewLazyLoader(callback func()) *LazyLoader { + return &LazyLoader{ + callback: callback, + } +} + +func (l *LazyLoader) Load() { + l.mu.Lock() + defer l.mu.Unlock() + + if l.loaded || l.loading { + return + } + + l.loading = true + go func() { + l.callback() + l.mu.Lock() + l.loaded = true + l.loading = false + l.mu.Unlock() + }() +} + +type VirtualList struct { + items []interface{} + renderer func(item interface{}) fyne.CanvasObject + visible int + start int + end int +} + +func NewVirtualList(items []interface{}, renderer func(item interface{}) fyne.CanvasObject, visible int) *VirtualList { + return &VirtualList{ + items: items, + renderer: renderer, + visible: visible, + end: visible, + } +} + +func (vl *VirtualList) GetVisibleItems() []fyne.CanvasObject { + if vl.start >= len(vl.items) { + return []fyne.CanvasObject{} + } + + if vl.end > len(vl.items) { + vl.end = len(vl.items) + } + + var result []fyne.CanvasObject + for i := vl.start; i < vl.end; i++ { + result = append(result, vl.renderer(vl.items[i])) + } + + return result +} + +func (vl *VirtualList) ScrollTo(index int) { + if index < 0 { + index = 0 + } + if index >= len(vl.items) { + index = len(vl.items) - 1 + } + + vl.start = index + vl.end = index + vl.visible + if vl.end > len(vl.items) { + vl.end = len(vl.items) + vl.start = vl.end - vl.visible + if vl.start < 0 { + vl.start = 0 + } + } +} + +type MemoryPool struct { + mu sync.Mutex + items []interface{} + new func() interface{} + reset func(interface{}) +} + +func NewMemoryPool(new func() interface{}, reset func(interface{})) *MemoryPool { + return &MemoryPool{ + new: new, + reset: reset, + } +} + +func (mp *MemoryPool) Get() interface{} { + mp.mu.Lock() + defer mp.mu.Unlock() + + if len(mp.items) > 0 { + item := mp.items[len(mp.items)-1] + mp.items = mp.items[:len(mp.items)-1] + mp.reset(item) + return item + } + + return mp.new() +} + +func (mp *MemoryPool) Put(item interface{}) { + mp.mu.Lock() + defer mp.mu.Unlock() + + mp.items = append(mp.items, item) +} diff --git a/ui/settings_screen.go b/ui/settings_screen.go new file mode 100644 index 0000000..03f4752 --- /dev/null +++ b/ui/settings_screen.go @@ -0,0 +1,186 @@ +package ui + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/widget" + + "MobileMkch/cache" +) + +type SettingsScreen struct { + uiManager *UIManager + content *fyne.Container +} + +func NewSettingsScreen(uiManager *UIManager) *SettingsScreen { + screen := &SettingsScreen{ + uiManager: uiManager, + content: container.NewVBox(), + } + screen.setupContent() + return screen +} + +func (ss *SettingsScreen) setupContent() { + ss.content.RemoveAll() + + header := widget.NewLabel("Настройки") + header.TextStyle = fyne.TextStyle{Bold: true} + ss.content.Add(header) + + themeLabel := widget.NewLabel("Тема:") + themeSelect := widget.NewSelect([]string{"Темная", "Светлая"}, func(theme string) { + if theme == "Темная" { + ss.uiManager.GetSettings().Theme = "dark" + } else { + ss.uiManager.GetSettings().Theme = "light" + } + ss.uiManager.SaveSettings() + }) + + if ss.uiManager.GetSettings().Theme == "dark" { + themeSelect.SetSelected("Темная") + } else { + themeSelect.SetSelected("Светлая") + } + + themeContainer := container.NewHBox(themeLabel, themeSelect) + ss.content.Add(themeContainer) + + autoRefreshCheck := widget.NewCheck("Авторефреш", func(checked bool) { + ss.uiManager.GetSettings().AutoRefresh = checked + ss.uiManager.SaveSettings() + }) + autoRefreshCheck.SetChecked(ss.uiManager.GetSettings().AutoRefresh) + ss.content.Add(autoRefreshCheck) + + showFilesCheck := widget.NewCheck("Показывать файлы", func(checked bool) { + ss.uiManager.GetSettings().ShowFiles = checked + ss.uiManager.SaveSettings() + }) + showFilesCheck.SetChecked(ss.uiManager.GetSettings().ShowFiles) + ss.content.Add(showFilesCheck) + + compactModeCheck := widget.NewCheck("Компактный режим", func(checked bool) { + ss.uiManager.GetSettings().CompactMode = checked + ss.uiManager.SaveSettings() + }) + compactModeCheck.SetChecked(ss.uiManager.GetSettings().CompactMode) + ss.content.Add(compactModeCheck) + + pageSizeLabel := widget.NewLabel("Размер страницы:") + pageSizeSelect := widget.NewSelect([]string{"5", "10", "15", "20"}, func(size string) { + switch size { + case "5": + ss.uiManager.GetSettings().PageSize = 5 + case "10": + ss.uiManager.GetSettings().PageSize = 10 + case "15": + ss.uiManager.GetSettings().PageSize = 15 + case "20": + ss.uiManager.GetSettings().PageSize = 20 + } + ss.uiManager.SaveSettings() + }) + + currentSize := ss.uiManager.GetSettings().PageSize + switch currentSize { + case 5: + pageSizeSelect.SetSelected("5") + case 10: + pageSizeSelect.SetSelected("10") + case 15: + pageSizeSelect.SetSelected("15") + case 20: + pageSizeSelect.SetSelected("20") + default: + pageSizeSelect.SetSelected("10") + } + + pageSizeContainer := container.NewHBox(pageSizeLabel, pageSizeSelect) + ss.content.Add(pageSizeContainer) + + lastBoardLabel := widget.NewLabel("Последняя доска:") + lastBoardValue := widget.NewLabel(ss.uiManager.GetLastBoard()) + if lastBoardValue.Text == "" { + lastBoardValue.SetText("Не выбрана") + } + lastBoardContainer := container.NewHBox(lastBoardLabel, lastBoardValue) + ss.content.Add(lastBoardContainer) + + ss.content.Add(widget.NewSeparator()) + cacheHeader := widget.NewLabel("Управление кэшем") + cacheHeader.TextStyle = fyne.TextStyle{Bold: true} + ss.content.Add(cacheHeader) + clearBoardsCacheBtn := widget.NewButton("Очистить кэш досок", func() { + cache.GetCache().Delete("boards") + ss.uiManager.ShowInfo("Кэш очищен", "Кэш досок очищен") + }) + ss.content.Add(clearBoardsCacheBtn) + clearThreadsCacheBtn := widget.NewButton("Очистить кэш тредов", func() { + boards, err := ss.uiManager.GetAPIClient().GetBoards() + if err == nil { + for _, board := range boards { + cache.GetCache().Delete("threads_" + board.Code) + } + } + ss.uiManager.ShowInfo("Кэш очищен", "Кэш тредов для всех досок очищен") + }) + ss.content.Add(clearThreadsCacheBtn) + clearThreadDetailsCacheBtn := widget.NewButton("Очистить кэш деталей тредов", func() { + // Удаляем кэш деталей тредов и комментариев + // Это более сложно, так как нужно знать ID тредов + // Пока просто очищаем весь кэш + cache.GetCache().Clear() + ss.uiManager.ShowInfo("Кэш очищен", "Кэш деталей тредов очищен") + }) + ss.content.Add(clearThreadDetailsCacheBtn) + + clearAllCacheBtn := widget.NewButton("Очистить весь кэш", func() { + cache.GetCache().Clear() + ss.uiManager.ShowInfo("Кэш очищен", "Весь кэш очищен") + }) + ss.content.Add(clearAllCacheBtn) + ss.content.Add(widget.NewSeparator()) + resetHeader := widget.NewLabel("Сброс настроек") + resetHeader.TextStyle = fyne.TextStyle{Bold: true} + ss.content.Add(resetHeader) + + resetButton := widget.NewButton("Сбросить настройки", func() { + ss.uiManager.GetSettings().Theme = "dark" + ss.uiManager.GetSettings().AutoRefresh = true + ss.uiManager.GetSettings().ShowFiles = true + ss.uiManager.GetSettings().CompactMode = false + ss.uiManager.GetSettings().LastBoard = "" + ss.uiManager.GetSettings().PageSize = 10 + ss.uiManager.SaveSettings() + ss.setupContent() // Перезагружаем интерфейс + }) + ss.content.Add(resetButton) + + ss.content.Add(widget.NewSeparator()) + + aboutButton := widget.NewButton("Об аппке", func() { + ss.uiManager.ShowAbout() + }) + ss.content.Add(aboutButton) + + ss.content.Refresh() +} + +func (ss *SettingsScreen) GetContent() fyne.CanvasObject { + return ss.content +} + +func (ss *SettingsScreen) GetTitle() string { + return "Настройки" +} + +func (ss *SettingsScreen) OnShow() { + ss.setupContent() +} + +func (ss *SettingsScreen) OnHide() { + ss.uiManager.SaveSettings() +} diff --git a/ui/thread_detail_screen.go b/ui/thread_detail_screen.go index 74bfab3..a62f0c8 100644 --- a/ui/thread_detail_screen.go +++ b/ui/thread_detail_screen.go @@ -170,15 +170,14 @@ func (tds *ThreadDetailScreen) displayThreadDetail() { func (tds *ThreadDetailScreen) createThreadContainer(thread *models.ThreadDetail) *fyne.Container { container := container.NewVBox() - info := fmt.Sprintf("Автор: %s | Дата: %s", - thread.Author, + info := fmt.Sprintf("Дата: %s", thread.GetCreationTime().Format("02.01.2006 15:04")) infoLabel := widget.NewLabel(info) infoLabel.TextStyle = fyne.TextStyle{Italic: true} container.Add(infoLabel) - if len(thread.Files) > 0 { + if len(thread.Files) > 0 && tds.uiManager.GetSettings().ShowFiles { filesContainer := tds.createFilesContainer(thread.Files) container.Add(filesContainer) } @@ -203,7 +202,7 @@ func (tds *ThreadDetailScreen) createCommentContainer(comment *models.Comment) * infoLabel.TextStyle = fyne.TextStyle{Italic: true} container.Add(infoLabel) - if len(comment.Files) > 0 { + if len(comment.Files) > 0 && tds.uiManager.GetSettings().ShowFiles { filesContainer := tds.createFilesContainer(comment.Files) container.Add(filesContainer) } diff --git a/ui/threads_screen.go b/ui/threads_screen.go index 7e2b052..bafb587 100644 --- a/ui/threads_screen.go +++ b/ui/threads_screen.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "strings" + "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" @@ -13,11 +14,16 @@ import ( ) type ThreadsScreen struct { - uiManager *UIManager - board *models.Board - content *fyne.Container - threads []models.Thread - loading bool + uiManager *UIManager + board *models.Board + content *fyne.Container + threads []models.Thread + loading bool + debouncer *Debouncer + currentPage int + pageSize int + totalPages int + pageContainer *fyne.Container } func NewThreadsScreen(uiManager *UIManager, board *models.Board) *ThreadsScreen { @@ -25,8 +31,13 @@ func NewThreadsScreen(uiManager *UIManager, board *models.Board) *ThreadsScreen uiManager: uiManager, board: board, loading: false, + pageSize: uiManager.GetSettings().PageSize, } + screen.debouncer = NewDebouncer(100*time.Millisecond, func() { + screen.displayThreads() + }) + screen.setupContent() return screen } @@ -82,7 +93,7 @@ func (ts *ThreadsScreen) loadThreads() { ts.threads = threads fyne.Do(func() { - ts.displayThreads() + ts.debouncer.Trigger() }) }() } @@ -135,25 +146,35 @@ func (ts *ThreadsScreen) displayThreads() { return } - for i := range ts.threads { - thread := ts.threads[i] + ts.totalPages = (len(ts.threads) + ts.pageSize - 1) / ts.pageSize + ts.pageContainer = container.NewVBox() - threadCard := ts.createThreadCard(&thread) - ts.content.Add(threadCard) + start := ts.currentPage * ts.pageSize + end := start + ts.pageSize + if end > len(ts.threads) { + end = len(ts.threads) } + for i := start; i < end; i++ { + thread := &ts.threads[i] + threadCard := ts.createThreadCard(thread) + ts.pageContainer.Add(threadCard) + } + + ts.addPaginationControls() + + ts.content.Add(ts.pageContainer) ts.content.Refresh() } func (ts *ThreadsScreen) createThreadCard(thread *models.Thread) fyne.CanvasObject { title := fmt.Sprintf("#%d: %s", thread.ID, thread.Title) - if len(title) > 50 { - title = title[:47] + "..." + if len(title) > 20 { + title = title[:17] + "..." } - info := fmt.Sprintf("Автор: %s | %s", - thread.Author, + info := fmt.Sprintf("Дата: %s", thread.GetCreationTime().Format("02.01.2006 15:04")) if thread.IsPinned() { @@ -164,7 +185,7 @@ func (ts *ThreadsScreen) createThreadCard(thread *models.Thread) fyne.CanvasObje info += fmt.Sprintf(" | ⭐ %d", rating) } - if len(thread.Files) > 0 { + if len(thread.Files) > 0 && ts.uiManager.GetSettings().ShowFiles { info += fmt.Sprintf(" | 📎 %d", len(thread.Files)) } @@ -193,3 +214,45 @@ func (ts *ThreadsScreen) createThreadCard(thread *models.Thread) fyne.CanvasObje func (ts *ThreadsScreen) RefreshThreads() { ts.loadThreads() } + +func (ts *ThreadsScreen) addPaginationControls() { + if ts.totalPages <= 1 { + return + } + + paginationContainer := container.NewHBox() + + prevButton := widget.NewButton("←", func() { + if ts.currentPage > 0 { + ts.currentPage-- + ts.displayThreads() + } + }) + if ts.currentPage == 0 { + prevButton.Disable() + } + paginationContainer.Add(prevButton) + + pageInfo := widget.NewLabel(fmt.Sprintf("Страница %d из %d", ts.currentPage+1, ts.totalPages)) + paginationContainer.Add(pageInfo) + + nextButton := widget.NewButton("→", func() { + if ts.currentPage < ts.totalPages-1 { + ts.currentPage++ + ts.displayThreads() + } + }) + if ts.currentPage >= ts.totalPages-1 { + nextButton.Disable() + } + paginationContainer.Add(nextButton) + + ts.pageContainer.Add(paginationContainer) +} + +func (ts *ThreadsScreen) goToPage(page int) { + if page >= 0 && page < ts.totalPages { + ts.currentPage = page + ts.displayThreads() + } +}