v2 (fckn yeaaah)

This commit is contained in:
Lain Iwakura 2025-08-05 20:08:15 +03:00
parent c9fcd918ce
commit a9d2b95886
No known key found for this signature in database
GPG Key ID: C7C18257F2ADC6F8
13 changed files with 1099 additions and 55 deletions

View File

@ -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` - детали треда
- `thread_detail_screen.go` - детали треда
- `settings_screen.go` - экран настроек
- `optimization.go` - утилиты оптимизации
- `mobile_optimizations.go` - оптимизации для мобильных устройств

View File

@ -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))
}

203
cache/cache.go vendored Normal file
View File

@ -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
}

View File

@ -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))

122
mobile_optimizations.go Normal file
View File

@ -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()
}

View File

@ -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"`
}

159
settings/settings.go Normal file
View File

@ -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
}

View File

@ -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)
})

View File

@ -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()
}

179
ui/optimization.go Normal file
View File

@ -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)
}

186
ui/settings_screen.go Normal file
View File

@ -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()
}

View File

@ -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)
}

View File

@ -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()
}
}