diff --git a/.github/ASSETS/donaton_timer_csseditor.png b/.github/ASSETS/donaton_timer_csseditor.png
new file mode 100644
index 0000000..2b02f99
Binary files /dev/null and b/.github/ASSETS/donaton_timer_csseditor.png differ
diff --git a/.github/ASSETS/donaton_timer_mainpage.png b/.github/ASSETS/donaton_timer_mainpage.png
new file mode 100644
index 0000000..4ae5247
Binary files /dev/null and b/.github/ASSETS/donaton_timer_mainpage.png differ
diff --git a/.github/ASSETS/donaton_timer_settings.png b/.github/ASSETS/donaton_timer_settings.png
new file mode 100644
index 0000000..8a0e675
Binary files /dev/null and b/.github/ASSETS/donaton_timer_settings.png differ
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..fa1db30
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,55 @@
+# ── AI / Personal helper files ──────────────────────────────────────────────
+PROJECT_CONTEXT.md
+.gitignore
+# ── Flutter / Dart ───────────────────────────────────────────────────────────
+# Build outputs
+build/
+.dart_tool/
+.flutter-plugins
+.flutter-plugins-dependencies
+.packages
+.pub-cache/
+.pub/
+
+# Generated files
+*.g.dart
+*.freezed.dart
+*.mocks.dart
+
+# Coverage
+coverage/
+
+# ── Windows ──────────────────────────────────────────────────────────────────
+windows/flutter/generated_plugin_registrant.cc
+windows/flutter/generated_plugin_registrant.h
+windows/flutter/generated_plugins.cmake
+windows/flutter/ephemeral/
+
+# ── IDE ──────────────────────────────────────────────────────────────────────
+.idea/
+.vscode/
+*.iml
+*.ipr
+*.iws
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# ── OS ───────────────────────────────────────────────────────────────────────
+Thumbs.db
+ehthumbs.db
+Desktop.ini
+$RECYCLE.BIN/
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# ── Logs ─────────────────────────────────────────────────────────────────────
+*.log
+logs/
+logs.txt
+
+# ── Installer and large binaries ──────────────────────────────────────────────
+installer/
+*.exe
diff --git a/README.md b/README.md
index 0191157..58ee178 100644
--- a/README.md
+++ b/README.md
@@ -1,56 +1,83 @@
-# 🎮 Таймер для донатона | Donathon Countdown Timer
+# Таймер для донатона | Donathon Countdown Timer
  [](https://github.com/MjKey/DonatonTimer/)
**DonatonTimer** — приложение для управления таймером донатона, которое интегрируется с несколькими донат-сервисами, позволяя отслеживать и управлять временем в зависимости от поступивших донатов. Также присутствует **оверлей таймера** для OBS с кастомизируемыми стилями!
-> Это моя первая разработка приложения на Flutter, до этого писал только на Python, думаю, получилось неплохо, пользуйтесь! 😺
+> Это моя первая разработка приложения на Flutter, до этого писал только на Python, думаю, получилось неплохо, пользуйтесь!
>
> Будет полезно тем, кто хочет себе удобный и функциональный таймер для донатона!
-**Автор:** [MjKey](https://mjkey.ru)
+**Автор:** [MjKey](https://mjkey.ru)
+**Поддержать проект:** [CloudTips](https://pay.cloudtips.ru/p/cf634f74) • [Dalink](https://dalink.to/mjk3y)
-## 📋 Инструкция в Wiki
+---
+
+## Инструкция в Wiki
+
+✦ [RU Wiki](https://github.com/MjKey/DonatonTimer/wiki/Настройка-и-использование-%5BRU%5D)
+✦ [EN Wiki](https://github.com/MjKey/DonatonTimer/wiki/Setting-and-using-%5BEN%5D)
+
+---
-✬ [RU](https://github.com/MjKey/DonatonTimer/wiki/Настройка-и-использование-%5BRU%5D) | [EN](https://github.com/MjKey/DonatonTimer/wiki/Setting-and-using-%5BEN%5D) (⸝⸝ᵕᴗᵕ⸝⸝)
+## Скриншоты
+
+
+
+
+ Главный экран
+ Управление и статистика
+
+ |
+
+ Настройки
+ Подключение донат-сервисов
+
+ |
+
+ CSS Генератор
+ Кастомизация оверлея
+
+ |
+
+
-## 🍌 Поддержка сервисов
+---
+
+## Поддержка сервисов
| Сервис | Статус | Комментарий |
|:--------------:|:------:|:-------------:|
-| DonationAlerts | ✅ | Работает |
-| Donate.Stream | ✅ | Работает |
-| DonatePay | ✅ | Работает |
-| DonateX | ✅ | Работает |
-| Donatty | ✅ | Работает (SSE) |
-| Streamer.bot | ✅ | Работает (WebSocket) |
-| iHAQ Donate | ❌ | В планах |
-| StreamElements | ❌ | В планах |
-
-## ✨ Что нового в v3.0.5
-
-- 🤖 **Streamer.bot** — интеграция через WebSocket с гибкой привязкой событий к суммам донатов
-- 🎁 **Donatty** — поддержка нового сервиса через SSE (Server-Sent Events)
-- 🔗 **DonateX** — поддержка нового сервиса донатов
-- 🔌 **Новые порты** — 7575 (HTTP) и 3434 (WS) по умолчанию, чтобы избежать конфликтов со Streamer.bot
-- ⚠️ **Предупреждение о старых портах** — автоматическая миграция с 8080/4040
-- ⏱️ **Фиксированное время** — режим фиксированного времени за каждый донат
-- ➖ **Режим убавления** — таймер можно настроить на убавление времени от донатов
-
-## ✨ Что было в v3.0.0
-
-- 🔗 **Мульти-сервис** — DonationAlerts, DonatePay, Donate.Stream одновременно
-- 🎨 **CSS генератор** — кастомизируемый оверлей для OBS с Google Fonts
-- 🎭 **Раздельные цвета** — часы, минуты, секунды могут быть разных цветов
-- ✨ **Анимации** — pulse, glow, bounce, blink для текста и разделителей
-- 📱 **Мобильное управление** — контроль таймера через QR код
-- 🔊 **Звуковые уведомления** — оповещения о донатах
-- 💾 **Автосохранение** — таймер сохраняется при закрытии
-- 🎮 **Ретро UI** — стильный 8-bit интерфейс (nes_ui)
-- 🔌 **Выбор сокета** — для DonationAlerts можно выбрать socket/socket1-5
-- 🔗 **Парсинг URL** — можно вставить ссылку виджета вместо токена
-
-## 🎯 Ключевые возможности
+| DonationAlerts | Да | Работает |
+| Donate.Stream | Да | Работает |
+| DonatePay | Да | Работает |
+| DonateX | Да | Работает |
+| Donatty | Да | Работает (SSE) |
+| CloudTips | Да | Работает (WebSocket) |
+| Streamer.bot | Да | BETA VER |
+| iHAQ Donate | Нет | В планах |
+| StreamElements | Нет | В планах |
+
+## Что нового в v3.0.X
+
+- **Мульти-сервис** — DonationAlerts, DonatePay, Donate.Stream, DonateX, Donatty одновременно
+- **CSS генератор** — кастомизируемый оверлей для OBS с Google Fonts
+- **Раздельные цвета** — часы, минуты, секунды могут быть разных цветов
+- **Анимации** — pulse, glow, bounce, blink для текста и разделителей
+- **Мобильное управление** — контроль таймера через QR код
+- **Звуковые уведомления** — оповещения о донатах
+- **Автосохранение** — таймер сохраняется при закрытии
+- **Ретро UI** — стильный 8-bit интерфейс (nes_ui)
+- **Выбор сокета** — для DonationAlerts можно выбрать socket/socket1-5
+- **Парсинг URL** — можно вставить ссылку виджета вместо токена
+- **Streamer.bot** — интеграция через WebSocket с гибкой привязкой событий к суммам донатов
+- **Конвертация** — конвертация валют в RUB
+- **Фиксированное время** — режим фиксированного времени за каждый донат
+- **Режим убавления времени** — таймер можно настроить на убавление времени от донатов
+
+---
+
+## Ключевые возможности
### Интерфейс программы под Windows
- Ретро 8-bit стиль
@@ -74,7 +101,7 @@
- Автоматическое прибавление времени от доната
- Настройка — сколько рублей = 1 час
- Поддержка нескольких сервисов одновременно
-- Интеграция со Streamer.bot (события → суммы донатов)
+- Интеграция со Streamer.bot (события -> суммы донатов)
### CSS генератор стилей
- 14 готовых пресетов (Cyberpunk, Matrix, Kawaii и др.)
@@ -85,7 +112,9 @@
### Мини-версия для Dok-Панели OBS
- Упрощённый интерфейс для использования в dok-панели OBS
-## 🛠️ Установка и запуск
+---
+
+## Установка и запуск
### Установка релизов
@@ -95,15 +124,18 @@
2. **Запустите установочный файл:**
- Дважды щелкните по скачанному файлу и следуйте инструкциям на экране
-### Установка артифактов
+### Установка артефактов (сырых версих / альфа версих если они есть)
-1. **Скачайте последний артифакт:**
+1. **Скачайте последний артефакт:**
- Перейдите в раздел [Actions](https://github.com/MjKey/DonatonTimer/actions), выберите последний удавшийся билд (с галочкой)
- - Снизу будет Artifacts → Latest — скачиваем, разархивируем в любую папку
+ - Снизу будет Artifacts -> Latest — скачиваем, разархивируем в любую папку
+ - **ТОЛЬКО ДЛЯ ТЕСТИРОВАНИЯ, ЭТО НЕ РЕЛИЗЫ!**
2. **Запустите таймер**
-## 🚀 Использование
+---
+
+## Использование
### URL адреса (по умолчанию)
@@ -113,30 +145,34 @@
| `http://localhost:7575/dashboard` | Веб-панель управления |
| `http://localhost:7575/mini` | Мини-версия для dok-панели OBS |
-> ⚠️ **Старые порты:** В версиях до v3.0.5 использовались порты 8080 (HTTP) и 4040 (WS). При первом запуске приложение предложит автоматически переключиться на новые порты 7575/3434.
+> **Старые порты:** В версиях до v3.0.6 использовались порты 8080 (HTTP) и 4040 (WS). При первом запуске приложение предложит автоматически переключиться на новые порты 7575/3434.
### Настройка dok-панели OBS
-В OBS Studio → Dok-панели (D) → Пользовательские dok-панели браузера (C)
+В OBS Studio -> Dok-панели -> Пользовательские dok-панели браузера
### Настройка Streamer.bot
Подробная инструкция в [Wiki](https://github.com/MjKey/DonatonTimer/wiki).
Кратко:
-1. В Streamer.bot включите **WebSocket Server** (Servers/Clients → WebSocket Server → Auto Start)
-2. В DonatonTimer → Настройки → Streamer.bot укажите адрес WebSocket (по умолчанию `ws://127.0.0.1:8080/`)
+1. В Streamer.bot включите **WebSocket Server** (Servers/Clients -> WebSocket Server -> Auto Start)
+2. В DonatonTimer -> Настройки -> Streamer.bot укажите адрес WebSocket (по умолчанию `ws://127.0.0.1:8080/`)
3. Включите сервис и нажмите **Сохранить**
4. Добавьте привязки событий: укажите Source (например, `Twitch`), Type (например, `Sub`) и эквивалентную сумму доната
-## 📁 Хранение данных
+---
+
+## Хранение данных
Настройки хранятся в:
```
%APPDATA%\MerryJoyKeyStudio\DonatonTimer\data.json
```
-## 🔧 Порты по умолчанию
+---
+
+## Порты по умолчанию
| Порт | Назначение |
|------|------------|
@@ -145,17 +181,19 @@
> Старые порты 8080/4040 были изменены для предотвращения конфликтов со Streamer.bot.
-## 💬 Вопросы и поддержка
+---
+
+## Вопросы и поддержка
Если у вас есть вопросы или вы столкнулись с проблемами, не стесняйтесь открыть issue на [GitHub](https://github.com/MjKey/DonatonTimer/issues).
-## 📝 Лицензия
+## Лицензия
Этот проект лицензируется под лицензией MIT — см. [LICENSE](LICENSE) для подробностей.
---
-## 🔨 Сборка из исходного кода
+## Сборка из исходного кода
```bash
# Клонировать репозиторий
@@ -184,9 +222,9 @@ flutter build windows
---
-**Обратный отсчёт для донатона** 🎮
+**Обратный отсчёт для донатона**
---
-Made with ❤️ by [MjKey](https://mjkey.ru)
-Буду рад любой финансовой поддержки!
+Made by [MjKey](https://mjkey.ru) with ❤️
+Буду рад любой финансовой поддержке!
diff --git a/README_EN.md b/README_EN.md
new file mode 100644
index 0000000..d2cb281
--- /dev/null
+++ b/README_EN.md
@@ -0,0 +1,217 @@
+# DonatonTimer | Countdown Timer
+
+  [](https://github.com/MjKey/DonatonTimer/)
+
+**DonatonTimer** is an application for managing donathon countdowns. It integrates with multiple donation platforms to track and add time dynamically based on incoming donations. It also features a fully customizable **timer overlay** for OBS with rich style generator!
+
+> This is my first application developed on Flutter (previously I only coded in Python). I hope you find this tool helpful and convenient for your streams!
+
+**Author:** [MjKey](https://mjkey.ru)
+**Support Project:** [CloudTips](https://pay.cloudtips.ru/p/cf634f74) • [Dalink](https://dalink.to/mjk3y)
+
+---
+
+## Wiki Documentation
+
+✦ [RU Wiki](https://github.com/MjKey/DonatonTimer/wiki/Настройка-и-использование-%5BRU%5D)
+✦ [EN Wiki](https://github.com/MjKey/DonatonTimer/wiki/Setting-and-using-%5BEN%5D)
+
+---
+
+## Screenshots
+
+
+
+
+ Main Screen
+ Timer control and stats
+
+ |
+
+ Settings
+ Donation services setup
+
+ |
+
+ CSS Generator
+ OBS Overlay Customization
+
+ |
+
+
+
+---
+
+## Supported Services
+
+| Service | Status | Comment |
+| :---: | :---: | :--- |
+| **DonationAlerts** | Yes | Working |
+| **Donate.Stream** | Yes | Working |
+| **DonatePay** | Yes | Working |
+| **DonateX** | Yes | Working |
+| **Donatty** | Yes | Working (SSE) |
+| **CloudTips** | Yes | Working (WebSocket) |
+| **Streamer.bot** | Yes | BETA VER |
+| **iHAQ Donate** | No | Planned |
+| **StreamElements** | No | Planned |
+
+---
+
+## What's New in v3.0.X
+
+- **Multi-service** — run DonationAlerts, DonatePay, Donate.Stream, DonateX, and Donatty simultaneously
+- **CSS Generator** — customizable OBS overlay with Google Fonts integration
+- **Separate Colors** customise different colors for hours, minutes, and seconds
+- **Animations** — pulse, glow, bounce, and blink effects for text and separators
+- **Mobile Control** — manage your timer from a phone using a simple QR code
+- **Sound Alerts** — sound notifications for incoming donations
+- **Auto-save** — timer state is automatically saved upon closing
+- **Retro UI** — stylish 8-bit aesthetic (nes_ui)
+- **Socket Selection** — manual socket configuration for DonationAlerts (socket/socket1-5)
+- **URL Parsing** — extract widget tokens automatically by pasting widget URLs
+- **Streamer.bot** — WebSocket integration with flexible mapping of events to donation equivalents
+- **Currency Conversion** — automatic conversion of USD, EUR, KZT, etc. into RUB using current Central Bank exchange rates
+- **Fixed Time** — option to add fixed time per donation event
+- **Time Subtraction Mode** — configure the timer to subtract time instead of adding it
+
+---
+
+## Key Features
+
+### Desktop App Interface (Windows)
+- Retro 8-bit styled theme
+- Real-time switching between Light and Dark themes
+- Intuitive control dashboard and connection status indicators
+- Helpful tooltips on hover for all buttons
+
+### Web Panel Administrator Interface
+- Remote Start/Stop and time adjustments
+- Interactive feed with recent donations and top-donators
+- Responsive mobile layout with fast QR-code connection
+
+### Donation Integrations
+- Automatic time calculations based on customizable rate formulas
+- Support for multiple donation platforms running at the same time
+- Flexible mapper to translate Streamer.bot events into virtual currencies
+- Built-in multi-source converter so you never lose international donations
+
+### OBS Style Generator
+- 14 pre-designed layout presets (Cyberpunk, Matrix, Neon, Kawaii, etc.)
+- Direct Google Fonts library integration
+- Fine-grained controls for individual HH:MM:SS styling
+
+---
+
+## Installation & Setup
+
+### Installing Official Releases
+
+1. **Download the installer:**
+ - Go to [Releases](https://github.com/MjKey/DonatonTimer/releases) and download the latest setup file: `DonatonTimer_vX.X.X_Setup.exe`
+
+2. **Run the setup:**
+ - Run the downloaded installer and follow the on-screen instructions.
+
+### Installing Artifacts (Bleeding-edge Alpha builds)
+
+1. **Download the latest artifact:**
+ - Go to [Actions](https://github.com/MjKey/DonatonTimer/actions), select the latest successful workflow run (indicated by green checkmark).
+ - Scroll down to *Artifacts* -> download *Latest*. Extract the ZIP archive to any directory.
+ - **ONLY FOR TESTING PURPOSES! THESE ARE NOT STABLE RELEASES!**
+
+2. **Run the Timer**
+
+---
+
+## Usage
+
+### Local Server URLs (Default)
+
+| URL | Description |
+| :--- | :--- |
+| `http://localhost:7575/timer` | Timer overlay for OBS Browser Source |
+| `http://localhost:7575/dashboard` | Web Administrator Panel |
+| `http://localhost:7575/mini` | Compact mini-version optimized for OBS Dok Panels |
+
+> **Port Migration Notice:** Since version v3.0.6, the app uses port `7575` (HTTP) and `3434` (WS) by default to prevent port conflicts with Streamer.bot (which defaults to 8080). Upon launching, the app will automatically suggest migrating your old config to these ports.
+
+### OBS Studio Dock Panel Setup
+In OBS Studio -> Docks -> Custom Browser Docks... -> Add name and paste `http://localhost:7575/mini`.
+
+### Streamer.bot Integration Guide
+Detailed instructions are available in the [Wiki](https://github.com/MjKey/DonatonTimer/wiki).
+
+In short:
+1. In Streamer.bot, enable the WebSocket server: `Servers/Clients` -> `WebSocket Server` -> Enable *Auto Start* and click *Start*.
+2. In DonatonTimer -> Settings -> *Streamer.bot*, enable the integration and specify the WebSocket address (default is `ws://127.0.0.1:8080/`).
+3. Add event mappings: specify the Event Source (e.g., `Twitch`), Event Type (e.g., `Sub`), and the virtual donation amount.
+
+---
+
+## Data Storage
+
+Application configuration and local database are stored at:
+```
+%APPDATA%\MerryJoyKeyStudio\DonatonTimer\data.json
+```
+
+---
+
+## Default Network Ports
+
+| Port | Description |
+|------|------------|
+| 7575 | HTTP Server (OBS overlay and dashboard) |
+| 3434 | WebSocket Server (for synchronisation and phone connection) |
+
+> Legacy ports 8080/4040 were migrated to avoid socket conflicts with Streamer.bot.
+
+---
+
+## Feedback & Support
+
+If you have any questions or encounter bugs, please feel free to open an Issue on [GitHub](https://github.com/MjKey/DonatonTimer/issues).
+
+## License
+
+This project is licensed under the MIT License — see the [LICENSE](LICENSE) file for details.
+
+---
+
+## Compiling from Source
+
+To compile this project locally, you must have Flutter SDK installed.
+
+```bash
+# 1. Clone the repository
+git clone https://github.com/MjKey/DonatonTimer.git
+cd DonatonTimer
+
+# 2. Get dependencies
+flutter pub get
+
+# 3. Run under Windows in debug mode
+flutter run -d windows
+
+# 4. Build release bundle
+flutter build windows
+```
+
+### Building Installer (Inno Setup)
+```bash
+# Compile project
+flutter build windows
+
+# Compile installer (requires ISCC and Inno Setup 6)
+"C:\Program Files (x86)\Inno Setup 6\ISCC.exe" setup.iss
+```
+
+---
+
+**Countdown Timer for Streams**
+
+---
+
+Made by [MjKey](https://mjkey.ru) with ❤️
+I will be highly grateful for any financial and informational support!
diff --git a/lang/en.json b/lang/en.json
index 4adfa71..4d3796f 100644
--- a/lang/en.json
+++ b/lang/en.json
@@ -145,6 +145,10 @@
"reset_all_confirm": "Are you sure? All settings, statistics and timer will be reset!",
"all_reset": "All data reset",
"confirm": "Confirm",
+ "currency_converter": "Currency Converter",
+ "enable_currency_conversion": "Enable conversion to RUB",
+ "currency_converter_desc": "If enabled, donations in USD, EUR, KZT, etc. will be converted to RUB at the current exchange rate. If disabled, non-RUB donations will be ignored.",
+ "converter_source": "Exchange rate source",
"logging": "Logging",
"logging_enabled": "Enable logging",
"logging_desc": "Writes events to logs.txt file next to the application",
@@ -171,6 +175,7 @@
"seconds_color": "Seconds color",
"or_widget_url": "or widget URL",
"socket_server": "Socket server",
+ "api_server": "API server",
"donatex_setup_note": "Paste full links here, not a raw token or widget ID.\n1. Open https://donatex.gg/streamer/donations and copy the recent donations widget link.\n2. Open https://donatex.gg/streamer/donation-widget and copy the donation widget link.",
"donatex_recent_url_label": "Recent donations widget URL",
"donatex_recent_url_hint": "https://donatex.gg/recent-donations?token=XXX&mode=normal",
@@ -198,5 +203,7 @@
"tooltip_style_generator": "Style Generator",
"tooltip_settings": "Settings",
"tooltip_language": "Switch language",
- "tooltip_theme": "Switch theme"
+ "tooltip_theme": "Switch theme",
+ "cloudtips_link_label": "CloudTips widget link",
+ "cloudtips_link_hint": "Paste your link from stream.cloudtips.ru/n/... — the token will be extracted automatically"
}
diff --git a/lang/ru.json b/lang/ru.json
index bfd3de4..ce4a78f 100644
--- a/lang/ru.json
+++ b/lang/ru.json
@@ -145,6 +145,10 @@
"reset_all_confirm": "Вы уверены? Все настройки, статистика и таймер будут сброшены!",
"all_reset": "Все данные сброшены",
"confirm": "Подтверждение",
+ "currency_converter": "Конвертер валют",
+ "enable_currency_conversion": "Включить конвертацию в RUB",
+ "currency_converter_desc": "Если включено, донаты в USD, EUR, KZT и др. будут переводиться в рубли по текущему курсу для расчета времени. Если выключено - такие донаты будут игнорироваться.",
+ "converter_source": "Источник курсов",
"logging": "Логирование",
"logging_enabled": "Включить логирование",
"logging_desc": "Записывает события в файл logs.txt рядом с приложением",
@@ -171,6 +175,7 @@
"seconds_color": "Цвет секунд",
"or_widget_url": "или ссылка виджета",
"socket_server": "Сервер сокета",
+ "api_server": "API-сервер",
"donatex_setup_note": "Сюда нужно вставлять именно полные ссылки, а не отдельно token или widget_id.\n1. Откройте https://donatex.gg/streamer/donations и скопируйте ссылку виджета последних донатов.\n2. Откройте https://donatex.gg/streamer/donation-widget и скопируйте ссылку виджета донатов.",
"donatex_recent_url_label": "Ссылка на виджет recent donations",
"donatex_recent_url_hint": "https://donatex.gg/recent-donations?token=XXX&mode=normal",
@@ -198,5 +203,7 @@
"tooltip_style_generator": "Генератор стилей",
"tooltip_settings": "Настройки",
"tooltip_language": "Сменить язык",
- "tooltip_theme": "Сменить тему"
+ "tooltip_theme": "Сменить тему",
+ "cloudtips_link_label": "Ссылка на виджет CloudTips",
+ "cloudtips_link_hint": "Скопируйте ссылку из stream.cloudtips.ru/n/... — токен будет извлечён автоматически"
}
diff --git a/lib/main.dart b/lib/main.dart
index ff3a018..4788607 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -14,6 +14,7 @@ import 'services/donate_pay_adapter.dart';
import 'services/donate_stream_adapter.dart';
import 'services/donatex_adapter.dart';
import 'services/donatty_adapter.dart';
+import 'services/cloudtips_adapter.dart';
import 'services/streamer_bot_adapter.dart';
import 'services/web_server_service.dart';
import 'services/sound_service.dart';
@@ -25,7 +26,7 @@ void main() async {
// Initialize logging
await LogManager.init();
- LogManager.info('Запуск приложения DonatonTimer v3.0.5 by MjKey');
+ LogManager.info('Запуск приложения DonatonTimer v3.0.6 by MjKey');
// Initialize window manager for desktop
await windowManager.ensureInitialized();
@@ -38,7 +39,7 @@ void main() async {
backgroundColor: Colors.transparent,
skipTaskbar: false,
titleBarStyle: TitleBarStyle.normal,
- title: 'DonatonTimer v3.0.5 by MjKey',
+ title: 'DonatonTimer v3.0.6 by MjKey',
);
windowManager.waitUntilReadyToShow(windowOptions, () async {
@@ -86,6 +87,8 @@ void main() async {
LogManager.info('Адаптер DonateX зарегистрирован');
donationService.registerAdapter(DonattyAdapter());
LogManager.info('Адаптер Donatty зарегистрирован');
+ donationService.registerAdapter(CloudTipsAdapter());
+ LogManager.info('Адаптер CloudTips зарегистрирован');
donationService.registerAdapter(StreamerBotAdapter());
LogManager.info('Адаптер StreamerBot зарегистрирован');
diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart
index 2dfebc5..3cf0569 100644
--- a/lib/models/app_settings.dart
+++ b/lib/models/app_settings.dart
@@ -15,6 +15,8 @@ class AppSettings {
final bool isFixedTimeMode;
final int fixedTimeMinutes;
final bool isSubtractionMode;
+ final bool enableCurrencyConversion;
+ final String currencyConverterSource;
const AppSettings({
this.minutesPerAmount = 600.0,
@@ -30,6 +32,8 @@ class AppSettings {
this.isFixedTimeMode = false,
this.fixedTimeMinutes = 1,
this.isSubtractionMode = false,
+ this.enableCurrencyConversion = false,
+ this.currencyConverterSource = 'cbr-xml',
});
/// Creates a copy with updated values.
@@ -47,6 +51,8 @@ class AppSettings {
bool? isFixedTimeMode,
int? fixedTimeMinutes,
bool? isSubtractionMode,
+ bool? enableCurrencyConversion,
+ String? currencyConverterSource,
}) {
return AppSettings(
minutesPerAmount: minutesPerAmount ?? this.minutesPerAmount,
@@ -62,6 +68,8 @@ class AppSettings {
isFixedTimeMode: isFixedTimeMode ?? this.isFixedTimeMode,
fixedTimeMinutes: fixedTimeMinutes ?? this.fixedTimeMinutes,
isSubtractionMode: isSubtractionMode ?? this.isSubtractionMode,
+ enableCurrencyConversion: enableCurrencyConversion ?? this.enableCurrencyConversion,
+ currencyConverterSource: currencyConverterSource ?? this.currencyConverterSource,
);
}
@@ -104,6 +112,8 @@ class AppSettings {
isFixedTimeMode: json['isFixedTimeMode'] as bool? ?? false,
fixedTimeMinutes: json['fixedTimeMinutes'] as int? ?? 1,
isSubtractionMode: json['isSubtractionMode'] as bool? ?? false,
+ enableCurrencyConversion: json['enableCurrencyConversion'] as bool? ?? false,
+ currencyConverterSource: json['currencyConverterSource'] as String? ?? 'cbr-xml',
);
}
@@ -122,6 +132,8 @@ class AppSettings {
'isFixedTimeMode': isFixedTimeMode,
'fixedTimeMinutes': fixedTimeMinutes,
'isSubtractionMode': isSubtractionMode,
+ 'enableCurrencyConversion': enableCurrencyConversion,
+ 'currencyConverterSource': currencyConverterSource,
'serviceConfigs': serviceConfigs.map(
(key, value) => MapEntry(key, value.toJson()),
),
diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart
index 8be27a2..e93931b 100644
--- a/lib/screens/main_screen.dart
+++ b/lib/screens/main_screen.dart
@@ -431,7 +431,7 @@ class _MainScreenState extends State {
),
const SizedBox(height: 8),
Text(
- '${localization.tr('version')}: 3.0.5',
+ '${localization.tr('version')}: 3.0.6',
style: const TextStyle(fontSize: 12),
),
const SizedBox(height: 24),
diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart
index 6138ce6..eb5f0e4 100644
--- a/lib/screens/settings_screen.dart
+++ b/lib/screens/settings_screen.dart
@@ -163,6 +163,31 @@ class _ServicesSettingsTabState extends State {
final _donattyTokenController = TextEditingController();
bool _donattyEnabled = false;
bool _donattyTokenVisible = false;
+ String _donattyApiServer = 'api-014';
+
+ static const List _donattyApiServers = [
+ 'api-014',
+ 'api',
+ 'api-015',
+ 'api-013',
+ 'api-010',
+ 'api-011',
+ 'api-012',
+ 'api-007',
+ 'api-009',
+ 'api-006',
+ 'api-001',
+ 'api-002',
+ 'api-003',
+ 'api-004',
+ 'api-005',
+ 'api-008',
+ ];
+
+ // CloudTips controllers
+ final _ctTokenController = TextEditingController();
+ bool _ctEnabled = false;
+ bool _ctTokenVisible = false;
// StreamerBot controllers
final _sbWsUrlController = TextEditingController();
@@ -231,6 +256,14 @@ class _ServicesSettingsTabState extends State {
if (donattyConfig != null) {
_donattyEnabled = donattyConfig.enabled;
_donattyTokenController.text = donattyConfig.getCredential('token') ?? '';
+ _donattyApiServer = donattyConfig.getCredential('apiServer') ?? 'api-014';
+ }
+
+ // CloudTips
+ final ctConfig = settings.getServiceConfig('CloudTips');
+ if (ctConfig != null) {
+ _ctEnabled = ctConfig.enabled;
+ _ctTokenController.text = ctConfig.getCredential('token') ?? '';
}
// StreamerBot
@@ -265,6 +298,7 @@ class _ServicesSettingsTabState extends State {
_dxWidgetUrlController.dispose();
_dxGroupUrlController.dispose();
_donattyTokenController.dispose();
+ _ctTokenController.dispose();
_sbWsUrlController.dispose();
_sbSourceController.dispose();
_sbTypeController.dispose();
@@ -380,6 +414,10 @@ class _ServicesSettingsTabState extends State {
_buildDonattySection(localization),
const SizedBox(height: 16),
+ // CloudTips
+ _buildCloudTipsSection(localization),
+ const SizedBox(height: 16),
+
// StreamerBot
_buildStreamerBotSection(localization),
],
@@ -813,11 +851,105 @@ class _ServicesSettingsTabState extends State {
),
),
const SizedBox(height: 16),
+
+ // API server dropdown
+ Text('${localization.tr('api_server')}:'),
+ const SizedBox(height: 8),
+ DropdownButton(
+ value: _donattyApiServer,
+ isExpanded: true,
+ items: _donattyApiServers.map((server) {
+ return DropdownMenuItem(
+ value: server,
+ child: Text('$server.donatty.com'),
+ );
+ }).toList(),
+ onChanged: (value) {
+ if (value != null) {
+ setState(() => _donattyApiServer = value);
+ }
+ },
+ ),
+ const SizedBox(height: 4),
+ Text(
+ 'По умолчанию api-014. Измените, если донаты не приходят.',
+ style: const TextStyle(fontSize: 10, color: Colors.grey),
+ ),
+ const SizedBox(height: 16),
+
NesButton.text(
type: NesButtonType.success,
text: localization.tr('save'),
onPressed: () => _saveServiceConfig('Donatty', _donattyEnabled, {
'token': _donattyTokenController.text,
+ 'apiServer': _donattyApiServer,
+ }),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildCloudTipsSection(LocalizationProvider localization) {
+ final status = _getAdapterStatus('CloudTips');
+ return NesContainer(
+ label: 'CloudTips',
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ NesCheckBox(
+ value: _ctEnabled,
+ onChange: (value) => setState(() => _ctEnabled = value),
+ ),
+ const SizedBox(width: 12),
+ Text(
+ _ctEnabled
+ ? localization.tr('enabled')
+ : localization.tr('disabled'),
+ ),
+ const Spacer(),
+ _buildStatusIndicator(status),
+ const SizedBox(width: 8),
+ ],
+ ),
+ const SizedBox(height: 16),
+ Text('${localization.tr('cloudtips_link_label')}:'),
+ const SizedBox(height: 8),
+ TextField(
+ controller: _ctTokenController,
+ obscureText: !_ctTokenVisible,
+ decoration: InputDecoration(
+ hintText: 'https://stream.cloudtips.ru/n/...',
+ border: const OutlineInputBorder(),
+ contentPadding: const EdgeInsets.symmetric(
+ horizontal: 12,
+ vertical: 8,
+ ),
+ suffixIcon: IconButton(
+ icon: Icon(
+ _ctTokenVisible ? Icons.visibility_off : Icons.visibility,
+ ),
+ onPressed: () =>
+ setState(() => _ctTokenVisible = !_ctTokenVisible),
+ ),
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ localization.tr('cloudtips_link_hint'),
+ style: const TextStyle(fontSize: 10, color: Colors.grey),
+ ),
+ const SizedBox(height: 16),
+ NesButton.text(
+ type: NesButtonType.success,
+ text: localization.tr('save'),
+ onPressed: () => _saveServiceConfig('CloudTips', _ctEnabled, {
+ 'token': _ctTokenController.text.trim(),
}),
),
],
@@ -1542,6 +1674,8 @@ class DataSettingsTab extends StatefulWidget {
class _DataSettingsTabState extends State {
bool _loggingEnabled = true;
+ bool _enableCurrencyConversion = false;
+ String _currencyConverterSource = 'er-api';
@override
void initState() {
@@ -1553,18 +1687,22 @@ class _DataSettingsTabState extends State {
final donationService = context.read();
if (donationService != null) {
_loggingEnabled = donationService.settings.loggingEnabled;
+ _enableCurrencyConversion = donationService.settings.enableCurrencyConversion;
+ _currencyConverterSource = donationService.settings.currencyConverterSource;
}
// Also sync with LogManager
_loggingEnabled = LogManager.enabled;
setState(() {});
}
- Future _saveLoggingSettings() async {
+ Future _saveDataSettings() async {
final donationService = context.read();
if (donationService == null) return;
final newSettings = donationService.settings.copyWith(
loggingEnabled: _loggingEnabled,
+ enableCurrencyConversion: _enableCurrencyConversion,
+ currencyConverterSource: _currencyConverterSource,
);
await donationService.updateSettings(newSettings);
@@ -1640,6 +1778,60 @@ class _DataSettingsTabState extends State {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
+ // Currency Converter settings
+ NesContainer(
+ label: localization.tr('currency_converter'),
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ NesCheckBox(
+ value: _enableCurrencyConversion,
+ onChange: (value) => setState(() => _enableCurrencyConversion = value),
+ ),
+ const SizedBox(width: 12),
+ Expanded(child: Text(localization.tr('enable_currency_conversion'))),
+ ],
+ ),
+ const SizedBox(height: 8),
+ Text(
+ localization.tr('currency_converter_desc'),
+ style: const TextStyle(fontSize: 11, color: Colors.grey),
+ ),
+ const SizedBox(height: 16),
+ Text('${localization.tr('converter_source')}:'),
+ const SizedBox(height: 8),
+ DropdownButton(
+ value: _currencyConverterSource,
+ isExpanded: true,
+ items: const [
+ DropdownMenuItem(value: 'cbr-xml', child: Text('ЦБ РФ (XML, по умолчанию)')),
+ DropdownMenuItem(value: 'cbr-json', child: Text('ЦБ РФ (JSON)')),
+ DropdownMenuItem(value: 'ratata', child: Text('Ratata Money (ratata.money)')),
+ DropdownMenuItem(value: 'er-api', child: Text('ER-API (open.er-api.com)')),
+ DropdownMenuItem(value: 'frankfurter', child: Text('Frankfurter (api.frankfurter.app)')),
+ ],
+ onChanged: (value) {
+ if (value != null) {
+ setState(() => _currencyConverterSource = value);
+ }
+ },
+ ),
+ const SizedBox(height: 16),
+ NesButton.text(
+ type: NesButtonType.normal,
+ text: localization.tr('save'),
+ onPressed: _saveDataSettings,
+ ),
+ ],
+ ),
+ ),
+ ),
+ const SizedBox(height: 16),
+
// Logging settings
NesContainer(
label: localization.tr('logging'),
@@ -1670,7 +1862,7 @@ class _DataSettingsTabState extends State {
NesButton.text(
type: NesButtonType.normal,
text: localization.tr('save'),
- onPressed: _saveLoggingSettings,
+ onPressed: _saveDataSettings,
),
],
),
diff --git a/lib/services/cloudtips_adapter.dart b/lib/services/cloudtips_adapter.dart
new file mode 100644
index 0000000..7e250ef
--- /dev/null
+++ b/lib/services/cloudtips_adapter.dart
@@ -0,0 +1,308 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+import 'package:logging/logging.dart';
+import '../models/donation.dart';
+import 'donation_service_adapter.dart';
+import 'log_manager.dart';
+
+/// Адаптер для CloudTips.
+/// Использует WebSocket (протокол SignalR/JSON) для получения донатов.
+///
+/// Протокол:
+/// 1. GET /notification/{token}/init -> {"connectionToken": "..."}
+/// 2. GET /connection?connectionToken=...&authType=Notifications -> {"status":"Ok"}
+/// 3. WSS /notification/socket?connectionToken=...
+/// -> Отправить: {"protocol":"json","version":1}\x1e
+/// -> Слушать type=1, target="SendNotification"
+/// -> Ping/pong: на {"type":6} отвечаем {"type":6}\x1e
+class CloudTipsAdapter extends BaseDonationServiceAdapter {
+ final Logger _logger = Logger('CloudTipsAdapter');
+
+ WebSocket? _webSocket;
+ Timer? _reconnectTimer;
+ Timer? _pingTimer;
+
+ int _reconnectAttempts = 0;
+ static const int _maxReconnectAttempts = 10;
+ static const Duration _reconnectDelay = Duration(seconds: 5);
+
+ /// Токен из URL ссылки (последний сегмент пути)
+ String? _linkToken;
+
+ /// Токен подключения, полученный от API
+ String? _connectionToken;
+
+ static const String _apiBase = 'streamers-api.cloudtips.ru';
+ static const String _wsBase = 'wss://streamers-api.cloudtips.ru:3030';
+
+ /// Разделитель сообщений SignalR (Record Separator, 0x1e)
+ static const String _signalrTerminator = '\x1e';
+
+ @override
+ String get serviceName => 'CloudTips';
+
+ /// Маскирует токен, оставляя только первые 5 символов
+ String _maskToken(String? token) {
+ if (token == null || token.isEmpty) return 'null';
+ if (token.length <= 5) return '***';
+ return '${token.substring(0, 5)}***';
+ }
+
+ /// Извлекает токен из ссылки вида:
+ /// https://stream.cloudtips.ru/n/AAAL4ItkOPY2iphRhUCY
+ static String? extractLinkToken(String input) {
+ input = input.trim();
+ // Пробуем разобрать как URL
+ final uri = Uri.tryParse(input);
+ if (uri != null && uri.pathSegments.isNotEmpty) {
+ // Берём последний сегмент пути
+ final last = uri.pathSegments.last;
+ if (last.isNotEmpty) return last;
+ }
+ // Если это просто голый токен без URL
+ if (!input.contains('/') && !input.contains(' ')) {
+ return input;
+ }
+ return null;
+ }
+
+ @override
+ Future connect(Map config) async {
+ await disconnect();
+
+ final input = config['token'] as String?;
+ if (input == null || input.isEmpty) {
+ LogManager.warning('CloudTips: токен или ссылка не указаны');
+ updateStatus(ConnectionStatus.error);
+ return;
+ }
+
+ _linkToken = extractLinkToken(input);
+ if (_linkToken == null) {
+ LogManager.warning('CloudTips: не удалось извлечь токен из "$input"');
+ updateStatus(ConnectionStatus.error);
+ return;
+ }
+
+ LogManager.info('CloudTips: токен ${_maskToken(_linkToken)}, инициализация...');
+ updateStatus(ConnectionStatus.connecting);
+ await _initConnection();
+ }
+
+ Future _initConnection() async {
+ try {
+ final httpClient = HttpClient()
+ ..connectionTimeout = const Duration(seconds: 15);
+
+ // 1. Получаем connectionToken
+ final initUrl = Uri.https(_apiBase, '/notification/$_linkToken/init');
+ final initReq = await httpClient.getUrl(initUrl);
+ final initResp = await initReq.close();
+
+ if (initResp.statusCode != 200) {
+ throw Exception('init failed: HTTP ${initResp.statusCode}');
+ }
+
+ final initBody = await initResp.transform(utf8.decoder).join();
+ final initJson = json.decode(initBody) as Map;
+ _connectionToken = initJson['connectionToken'] as String?;
+ httpClient.close();
+
+ if (_connectionToken == null || _connectionToken!.isEmpty) {
+ throw Exception('connectionToken not found in response');
+ }
+
+ LogManager.info('CloudTips: connectionToken получен (${_maskToken(_connectionToken)}), подключение к WS...');
+
+ // 2. Подключаемся к WebSocket
+ final wsUrl = '$_wsBase/notification/socket?connectionToken=${Uri.encodeComponent(_connectionToken!)}';
+ _webSocket = await WebSocket.connect(wsUrl);
+
+ LogManager.info('CloudTips: WebSocket подключён, отправка handshake...');
+ updateStatus(ConnectionStatus.connected);
+ _reconnectAttempts = 0;
+
+ // 3. SignalR handshake
+ _webSocket!.add('${json.encode({"protocol": "json", "version": 1})}$_signalrTerminator');
+
+ // 4. Слушаем входящие сообщения
+ _webSocket!.listen(
+ _handleMessage,
+ onError: (e) {
+ _logger.severe('WebSocket error: $e');
+ LogManager.error('CloudTips: ошибка WS - $e');
+ _handleDisconnect();
+ },
+ onDone: () {
+ LogManager.warning('CloudTips: WS соединение закрыто');
+ _handleDisconnect();
+ },
+ );
+
+ // 5. Periodic ping каждые 15 секунд (на случай если сервер не пингует)
+ _pingTimer?.cancel();
+ _pingTimer = Timer.periodic(const Duration(seconds: 15), (_) {
+ if (_webSocket?.readyState == WebSocket.open) {
+ _webSocket!.add('${json.encode({"type": 6})}$_signalrTerminator');
+ }
+ });
+ } catch (e, st) {
+ _logger.severe('CloudTips connect error: $e\n$st');
+ LogManager.error('CloudTips: ошибка подключения - $e');
+ _handleDisconnect();
+ }
+ }
+
+ void _handleMessage(dynamic raw) {
+ final text = raw.toString();
+ // SignalR разделяет сообщения символом \x1e
+ final parts = text.split(_signalrTerminator);
+ for (final part in parts) {
+ final trimmed = part.trim();
+ if (trimmed.isEmpty) continue;
+ try {
+ final msg = json.decode(trimmed) as Map;
+ _processMessage(msg);
+ } catch (_) {
+ // Игнорируем нераспознанные сообщения
+ }
+ }
+ }
+
+ void _processMessage(Map msg) {
+ final type = msg['type'] as int?;
+
+ // Ping - отвечаем pong
+ if (type == 6) {
+ if (_webSocket?.readyState == WebSocket.open) {
+ _webSocket!.add('${json.encode({"type": 6})}$_signalrTerminator');
+ }
+ return;
+ }
+
+ // type=1 — это вызов метода на клиенте
+ if (type == 1) {
+ final target = msg['target'] as String?;
+ if (target != 'SendNotification') return;
+
+ final arguments = msg['arguments'] as List?;
+ if (arguments == null || arguments.isEmpty) return;
+
+ final payload = arguments[0] as Map?;
+ if (payload == null) return;
+
+ _processDonation(payload);
+ }
+ }
+
+ void _processDonation(Map payload) {
+ try {
+ // Сумму берём ТОЛЬКО из поля title по шаблону "... N₽."
+ // Защита: ищем последнее вхождение числа перед знаком ₽
+ final title = payload['title'] as String? ?? '';
+ final amount = _extractAmountFromTitle(title);
+ if (amount == null || amount <= 0) {
+ LogManager.warning('CloudTips: не удалось извлечь сумму из "$title"');
+ return;
+ }
+
+ // Извлекаем имя из title: всё до " отправил"
+ final username = _extractUsernameFromTitle(title);
+
+ final comment = payload['comment'] as String?;
+ final id = payload['id'] as String? ?? DateTime.now().millisecondsSinceEpoch.toString();
+
+ final donation = Donation(
+ id: '${serviceName}_$id',
+ serviceName: serviceName,
+ username: username,
+ amount: amount,
+ currency: 'RUB',
+ message: comment,
+ timestamp: DateTime.now(),
+ );
+
+ _logger.info('Donation: $username - $amount RUB');
+ LogManager.info('CloudTips: донат от $username - $amount RUB');
+ emitDonation(donation);
+ } catch (e, st) {
+ _logger.severe('Error processing CloudTips donation: $e\n$st');
+ LogManager.error('CloudTips: ошибка обработки доната - $e');
+ }
+ }
+
+ /// Извлекает сумму из строки вида "Имя отправил 49₽."
+ /// Берём ПОСЛЕДНЕЕ число непосредственно перед знаком ₽.
+ /// Это защищает от случаев, когда пользователь вписывает число/₽ в имя.
+ double? _extractAmountFromTitle(String title) {
+ // Ищем: последовательность цифр (возможно с запятой/точкой), стоящая прямо перед "₽"
+ final matches = RegExp(r'(\d[\d\s]*)₽').allMatches(title);
+ if (matches.isEmpty) return null;
+
+ // Берём ПОСЛЕДНЕЕ совпадение
+ final last = matches.last;
+ final numberStr = last.group(1)?.replaceAll(RegExp(r'\s'), '').trim();
+ if (numberStr == null || numberStr.isEmpty) return null;
+ return double.tryParse(numberStr.replaceAll(',', '.'));
+ }
+
+ /// Извлекает имя из строки "Имя отправил 49₽."
+ /// Обрезаем по последнему вхождению " отправил "
+ String _extractUsernameFromTitle(String title) {
+ final idx = title.lastIndexOf(' отправил ');
+ if (idx > 0) {
+ return title.substring(0, idx).trim();
+ }
+ return title;
+ }
+
+ void _handleDisconnect() {
+ _pingTimer?.cancel();
+ _pingTimer = null;
+ if (status != ConnectionStatus.disconnected) {
+ updateStatus(ConnectionStatus.error);
+ _scheduleReconnect();
+ }
+ }
+
+ void _scheduleReconnect() {
+ if (_reconnectAttempts >= _maxReconnectAttempts) {
+ _logger.severe('Max reconnect attempts reached');
+ LogManager.error('CloudTips: превышено число попыток переподключения');
+ updateStatus(ConnectionStatus.error);
+ return;
+ }
+
+ _reconnectTimer?.cancel();
+ final delay = _reconnectDelay * (_reconnectAttempts + 1);
+ _reconnectTimer = Timer(delay, () {
+ _reconnectAttempts++;
+ LogManager.info('CloudTips: попытка переподключения #$_reconnectAttempts...');
+ updateStatus(ConnectionStatus.reconnecting);
+ _initConnection();
+ });
+ }
+
+ @override
+ Future disconnect() async {
+ LogManager.info('CloudTips: отключение...');
+ updateStatus(ConnectionStatus.disconnected);
+
+ _reconnectTimer?.cancel();
+ _pingTimer?.cancel();
+ _reconnectTimer = null;
+ _pingTimer = null;
+
+ await _webSocket?.close();
+ _webSocket = null;
+
+ LogManager.info('CloudTips: отключено');
+ }
+
+ @override
+ Future dispose() async {
+ await disconnect();
+ await super.dispose();
+ }
+}
diff --git a/lib/services/currency_converter_service.dart b/lib/services/currency_converter_service.dart
new file mode 100644
index 0000000..a59c2af
--- /dev/null
+++ b/lib/services/currency_converter_service.dart
@@ -0,0 +1,153 @@
+import 'dart:convert';
+import 'dart:io';
+import 'package:logging/logging.dart';
+import 'log_manager.dart';
+
+class CurrencyConverterService {
+ static final CurrencyConverterService _instance = CurrencyConverterService._internal();
+ factory CurrencyConverterService() => _instance;
+ CurrencyConverterService._internal();
+
+ final _logger = Logger('CurrencyConverterService');
+
+ Map _rates = {}; // Currency -> Rate (relative to base)
+ DateTime? _lastUpdate;
+
+ bool get hasRates => _rates.isNotEmpty && _rates.containsKey('RUB');
+ DateTime? get lastUpdate => _lastUpdate;
+
+ Future fetchRates(String source) async {
+ try {
+ final client = HttpClient()..connectionTimeout = const Duration(seconds: 10);
+
+ if (source == 'er-api') {
+ final url = Uri.parse('https://open.er-api.com/v6/latest/USD');
+ final request = await client.getUrl(url);
+ final response = await request.close();
+
+ if (response.statusCode == 200) {
+ final body = await response.transform(utf8.decoder).join();
+ final data = json.decode(body) as Map;
+ final ratesData = data['rates'] as Map;
+
+ _rates.clear();
+ ratesData.forEach((key, value) {
+ _rates[key] = (value as num).toDouble();
+ });
+ _lastUpdate = DateTime.now();
+ LogManager.info('Конвертер валют обновлен (ER-API). База: USD, RUB: ${_rates['RUB']}');
+ } else {
+ throw Exception('ER-API returned ${response.statusCode}');
+ }
+ } else if (source == 'frankfurter') {
+ // У Frankfurter API по умолчанию база EUR
+ final url = Uri.parse('https://api.frankfurter.app/latest');
+ final request = await client.getUrl(url);
+ final response = await request.close();
+
+ if (response.statusCode == 200) {
+ final body = await response.transform(utf8.decoder).join();
+ final data = json.decode(body) as Map;
+ final ratesData = data['rates'] as Map;
+
+ _rates.clear();
+ ratesData.forEach((key, value) {
+ _rates[key] = (value as num).toDouble();
+ });
+ final base = data['base'] as String? ?? 'EUR';
+ _rates[base] = 1.0;
+ _lastUpdate = DateTime.now();
+ LogManager.info('Конвертер валют обновлен (Frankfurter). База: $base, RUB: ${_rates['RUB']}');
+ } else {
+ throw Exception('Frankfurter returned ${response.statusCode}');
+ }
+ } else if (source == 'ratata') {
+ final url = Uri.parse('https://ratata.money/api/v1/rates/latest?base=RUB');
+ final request = await client.getUrl(url);
+ final response = await request.close();
+
+ if (response.statusCode == 200) {
+ final body = await response.transform(utf8.decoder).join();
+ final data = json.decode(body) as Map;
+ final ratesData = data['rates'] as Map;
+
+ _rates.clear();
+ ratesData.forEach((key, value) {
+ _rates[key] = (value as num).toDouble();
+ });
+ _rates['RUB'] = 1.0;
+ _lastUpdate = DateTime.now();
+ LogManager.info('Конвертер валют обновлен (Ratata). База: RUB, USD: ${_rates['USD']}');
+ } else {
+ throw Exception('Ratata returned ${response.statusCode}');
+ }
+ } else if (source == 'cbr-json') {
+ final url = Uri.parse('https://www.cbr-xml-daily.ru/latest.js');
+ final request = await client.getUrl(url);
+ final response = await request.close();
+
+ if (response.statusCode == 200) {
+ final body = await response.transform(utf8.decoder).join();
+ final data = json.decode(body) as Map;
+ final ratesData = data['rates'] as Map;
+
+ _rates.clear();
+ ratesData.forEach((key, value) {
+ _rates[key] = (value as num).toDouble();
+ });
+ _rates['RUB'] = 1.0;
+ _lastUpdate = DateTime.now();
+ LogManager.info('Конвертер валют обновлен (CBR-JSON). База: RUB, USD: ${_rates['USD']}');
+ } else {
+ throw Exception('CBR JSON returned ${response.statusCode}');
+ }
+ } else if (source == 'cbr-xml') {
+ final url = Uri.parse('https://www.cbr.ru/scripts/XML_daily.asp');
+ final request = await client.getUrl(url);
+ final response = await request.close();
+
+ if (response.statusCode == 200) {
+ // CBR returns windows-1251, so we decode as latin1/ascii to safely extract English tags and digits
+ final bytes = await response.expand((b) => b).toList();
+ final body = String.fromCharCodes(bytes);
+
+ final regex = RegExp(r'([A-Z]+)<\/CharCode>.*?(\d+)<\/Nominal>.*?([\d,]+)<\/Value>');
+ final matches = regex.allMatches(body);
+
+ _rates.clear();
+ for (final match in matches) {
+ final code = match.group(1)!;
+ final nominal = double.parse(match.group(2)!);
+ final valueStr = match.group(3)!.replaceAll(',', '.');
+ final value = double.parse(valueStr);
+
+ // value is how many RUB for `nominal` amount of `code`
+ // Example: 1 USD = 90 RUB. _rates['USD'] should be 1 / 90.
+ _rates[code] = nominal / value;
+ }
+ _rates['RUB'] = 1.0;
+ _lastUpdate = DateTime.now();
+ LogManager.info('Конвертер валют обновлен (CBR-XML). База: RUB, USD: ${_rates['USD']}');
+ } else {
+ throw Exception('CBR XML returned ${response.statusCode}');
+ }
+ }
+ } catch (e) {
+ _logger.warning('Ошибка обновления курсов: $e');
+ LogManager.error('Конвертер: ошибка загрузки курсов ($source) - $e');
+ }
+ }
+
+ /// Converts an amount from [fromCurrency] to RUB.
+ /// Returns null if conversion is impossible (e.g. unknown currency or no rates).
+ double? convertToRub(double amount, String fromCurrency) {
+ if (fromCurrency.toUpperCase() == 'RUB') return amount;
+ if (_rates.isEmpty || !_rates.containsKey('RUB')) return null;
+
+ final cur = fromCurrency.toUpperCase();
+ if (!_rates.containsKey(cur)) return null;
+
+ final rateToRub = _rates['RUB']! / _rates[cur]!;
+ return amount * rateToRub;
+ }
+}
diff --git a/lib/services/donate_pay_adapter.dart b/lib/services/donate_pay_adapter.dart
index ae9ed6e..8307e3a 100644
--- a/lib/services/donate_pay_adapter.dart
+++ b/lib/services/donate_pay_adapter.dart
@@ -241,11 +241,7 @@ class DonatePayAdapter extends BaseDonationServiceAdapter {
final currency = transaction?['currency'] as String? ?? 'RUB';
final donationMessage = vars['comment'] as String?;
- // Only process RUB donations
- if (currency != 'RUB') {
- LogManager.info('DonatePay: пропуск не-RUB доната ($currency)');
- return;
- }
+ // Валюта больше не фильтруется здесь, это делает DonationService
final donation = Donation(
id: '${serviceName}_$id',
diff --git a/lib/services/donate_stream_adapter.dart b/lib/services/donate_stream_adapter.dart
index 3ee03c3..3720df4 100644
--- a/lib/services/donate_stream_adapter.dart
+++ b/lib/services/donate_stream_adapter.dart
@@ -173,11 +173,7 @@ class DonateStreamAdapter extends BaseDonationServiceAdapter {
final currency = alertData['currency'] as String? ?? 'RUB';
final message = alertData['message'] as String?;
- // Only process RUB donations
- if (currency != 'RUB') {
- _logger.info('Skipping non-RUB donation: currency=$currency');
- return;
- }
+ // Валюта больше не фильтруется здесь, это делает DonationService
final donation = Donation(
id: '${serviceName}_$id',
diff --git a/lib/services/donation_alerts_adapter.dart b/lib/services/donation_alerts_adapter.dart
index 6641235..831e769 100644
--- a/lib/services/donation_alerts_adapter.dart
+++ b/lib/services/donation_alerts_adapter.dart
@@ -210,15 +210,11 @@ class DonationAlertsAdapter extends BaseDonationServiceAdapter {
return;
}
- // Only process RUB donations
- if (currency != 'RUB') {
- _logger.info('Skipping non-RUB donation: $currency');
- return;
- }
+ // Валюта больше не фильтруется здесь, это делает DonationService
final id = donationData['id']?.toString() ?? DateTime.now().millisecondsSinceEpoch.toString();
final username = donationData['username'] as String? ?? 'Anonymous';
- final amount = _parseDoubleField(donationData['amount_main']);
+ final amount = _parseDoubleField(donationData['amount']);
final donationMessage = donationData['message'] as String?;
final donation = Donation(
diff --git a/lib/services/donation_service.dart b/lib/services/donation_service.dart
index 1cde3d5..cc0654a 100644
--- a/lib/services/donation_service.dart
+++ b/lib/services/donation_service.dart
@@ -8,6 +8,7 @@ import '../models/statistics.dart';
import 'donation_service_adapter.dart';
import 'storage_service.dart';
import 'log_manager.dart';
+import 'currency_converter_service.dart';
/// Сервис для управления адаптерами донат-сервисов и обработки донатов.
class DonationService extends ChangeNotifier {
@@ -74,6 +75,9 @@ class DonationService extends ChangeNotifier {
_rate = savedSettings.minutesPerAmount;
}
+ // Загружаем курсы валют
+ CurrencyConverterService().fetchRates(_settings.currencyConverterSource);
+
// Load statistics
final savedStats = _storageService.loadStatistics();
if (savedStats != null) {
@@ -85,9 +89,16 @@ class DonationService extends ChangeNotifier {
/// Updates the application settings.
Future updateSettings(AppSettings newSettings) async {
+ final oldSource = _settings.currencyConverterSource;
_settings = newSettings;
_rate = newSettings.minutesPerAmount;
await _storageService.saveSettings(newSettings);
+
+ // Обновляем курсы, если источник изменился или если они еще не загружены
+ if (oldSource != newSettings.currencyConverterSource || !CurrencyConverterService().hasRates) {
+ CurrencyConverterService().fetchRates(newSettings.currencyConverterSource);
+ }
+
notifyListeners();
}
@@ -193,12 +204,38 @@ class DonationService extends ChangeNotifier {
}
}
+ // Конвертация валют
+ double effectiveAmount = donation.amount;
+ String effectiveCurrency = donation.currency.toUpperCase();
+
+ if (effectiveCurrency != 'RUB') {
+ if (!_settings.enableCurrencyConversion) {
+ LogManager.info('Пропуск доната не в RUB ($effectiveCurrency), т.к. конвертация выключена: ${donation.username}');
+ return;
+ }
+
+ final converter = CurrencyConverterService();
+ final converted = converter.convertToRub(donation.amount, effectiveCurrency);
+
+ if (converted == null) {
+ LogManager.warning('Не удалось конвертировать ${donation.amount} $effectiveCurrency в RUB. Донат пропущен.');
+ return;
+ }
+
+ LogManager.info('Конвертация: ${donation.amount} $effectiveCurrency -> ${converted.toStringAsFixed(2)} RUB');
+ effectiveAmount = converted;
+ }
+
// Рассчитываем время для добавления
int secondsToAdd;
if (_settings.isFixedTimeMode) {
secondsToAdd = _settings.fixedTimeMinutes * 60;
} else {
- secondsToAdd = donation.calculateSeconds(_rate, _settings.timePerAmountMinutes);
+ if (_rate <= 0) {
+ secondsToAdd = 0;
+ } else {
+ secondsToAdd = ((effectiveAmount / _rate) * _settings.timePerAmountMinutes * 60).round();
+ }
}
if (_settings.isSubtractionMode) {
diff --git a/lib/services/donatty_adapter.dart b/lib/services/donatty_adapter.dart
index 823f244..dcb19a8 100644
--- a/lib/services/donatty_adapter.dart
+++ b/lib/services/donatty_adapter.dart
@@ -24,10 +24,18 @@ class DonattyAdapter extends BaseDonationServiceAdapter {
String? _groupId;
String? _linkToken;
String? _accessToken;
+ String _apiServer = 'api-014';
@override
String get serviceName => 'Donatty';
+ /// Маскирует токен, оставляя только первые 5 символов
+ String _maskToken(String? token) {
+ if (token == null || token.isEmpty) return 'null';
+ if (token.length <= 5) return '***';
+ return '${token.substring(0, 5)}***';
+ }
+
/// Extracts tokens from the URL.
/// Supports: https://widgets.donatty.com/group/?ref={group_id}&token={link_token}
static Map? extractTokens(String input) {
@@ -46,6 +54,9 @@ class DonattyAdapter extends BaseDonationServiceAdapter {
@override
Future connect(Map config) async {
+ // Отключаемся от предыдущего соединения, если оно было
+ await disconnect();
+
final urlOrTokens = config['token'] as String?;
if (urlOrTokens == null || urlOrTokens.isEmpty) {
@@ -59,6 +70,7 @@ class DonattyAdapter extends BaseDonationServiceAdapter {
if (tokens != null) {
_groupId = tokens['group_id'];
_linkToken = tokens['link_token'];
+ LogManager.info('Donatty: URL parsed, groupId=${_maskToken(_groupId)}, linkToken=${_maskToken(_linkToken)}');
} else {
// It might be a token configuration, but we strictly need both group_id and link_token or a full link
_logger.warning('Invalid Donatty link format. Ensure it contains ref and token.');
@@ -67,9 +79,11 @@ class DonattyAdapter extends BaseDonationServiceAdapter {
return;
}
+ _apiServer = config['apiServer'] as String? ?? 'api-014';
+
updateStatus(ConnectionStatus.connecting);
- _logger.info('Connecting to Donatty...');
- LogManager.info('Donatty: получение access_token...');
+ _logger.info('Connecting to Donatty via $_apiServer...');
+ LogManager.info('Donatty: получение access_token (сервер: $_apiServer)...');
await _initConnection();
}
@@ -80,7 +94,7 @@ class DonattyAdapter extends BaseDonationServiceAdapter {
..connectionTimeout = const Duration(seconds: 15);
// 1. Get access token
- final authUrl = Uri.parse('https://api-014.donatty.com/auth/tokens/$_linkToken');
+ final authUrl = Uri.parse('https://$_apiServer.donatty.com/auth/tokens/$_linkToken');
final authRequest = await _httpClient!.getUrl(authUrl);
final authResponse = await authRequest.close();
@@ -97,10 +111,10 @@ class DonattyAdapter extends BaseDonationServiceAdapter {
throw Exception('Access token not found in response');
}
- LogManager.info('Donatty: access_token получен, подключение к SSE...');
+ LogManager.info('Donatty: access_token получен (${_maskToken(_accessToken)}), подключение к SSE...');
// 2. Connect to SSE
- final sseUrl = Uri.parse('https://api-014.donatty.com/widgets/$_groupId/sse?jwt=$_accessToken');
+ final sseUrl = Uri.parse('https://$_apiServer.donatty.com/widgets/$_groupId/sse?jwt=$_accessToken');
_request = await _httpClient!.getUrl(sseUrl);
_request!.headers.set('Accept', 'text/event-stream');
_request!.headers.set('Cache-Control', 'no-cache');
@@ -163,6 +177,14 @@ class DonattyAdapter extends BaseDonationServiceAdapter {
} catch (e) {
_logger.warning('Error parsing SSE line: $e\nLine was: $line');
}
+ } else if (line.startsWith('{')) {
+ // Возможно, они шлют чистый JSON без префикса data:
+ try {
+ final data = json.decode(line) as Map;
+ _handleEventData(data);
+ } catch (e) {
+ _logger.warning('Error parsing pure JSON line: $e\nLine was: $line');
+ }
}
}
@@ -193,12 +215,21 @@ class DonattyAdapter extends BaseDonationServiceAdapter {
}
}
}
+ } else if (action == 'DATA') {
+ // Donatty присылает данные напрямую с action "DATA"
+ final eventData = data['data'] as Map?;
+ if (eventData != null) {
+ _processDonation(eventData);
+ }
}
}
void _processDonation(Map eventData) {
final eventType = eventData['streamEventType'] as String?;
- if (eventType != 'DONATTY_DONATION') return;
+
+ if (eventType != 'DONATTY_DONATION') {
+ return;
+ }
try {
final subscriber = eventData['subscriber'] as String? ?? 'Anonymous';
diff --git a/lib/services/services.dart b/lib/services/services.dart
index 4d47c41..40d552d 100644
--- a/lib/services/services.dart
+++ b/lib/services/services.dart
@@ -13,4 +13,5 @@ export 'donate_pay_adapter.dart';
export 'donate_stream_adapter.dart';
export 'donatex_adapter.dart';
export 'donatty_adapter.dart';
+export 'cloudtips_adapter.dart';
export 'streamer_bot_adapter.dart';
diff --git a/setup.iss b/setup.iss
index fe44095..5214c1b 100644
--- a/setup.iss
+++ b/setup.iss
@@ -1,10 +1,10 @@
-; DonatonTimer v3.0.5 Setup Script
+; DonatonTimer v3.0.6 Setup Script
; Автор: MjKey (https://mjkey.ru)
; Проект: https://github.com/MjKey/DonatonTimer
; Inno Setup 6.2+
#define MyAppName "DonatonTimer"
-#define MyAppVersion "3.0.5"
+#define MyAppVersion "3.0.6"
#define MyAppPublisher "MjKey"
#define MyAppURL "https://github.com/MjKey/DonatonTimer"
#define MyAppExeName "donaton_timer.exe"
diff --git a/streamerbot.md b/streamerbot.md
index 0de7a8e..cef19bd 100644
--- a/streamerbot.md
+++ b/streamerbot.md
@@ -7,13 +7,15 @@
---
-## ⚙️ Шаг 1. Настройка Streamer.bot
+## Шаг 1. Настройка Streamer.bot
1. Откройте **Streamer.bot**.
2. Перейдите во вкладку `Servers/Clients` -> `WebSocket Server`.
3. Убедитесь, что стоит галочка **Auto Start**, а сам сервер запущен (статус *Started*).
4. Запомните **IP** и **Port**. По умолчанию это `127.0.0.1` и `8080`.
-## ⚙️ Шаг 2. Подключение в Donaton Timer
+---
+
+## Шаг 2. Подключение в Donaton Timer
1. Откройте **Donaton Timer** и нажмите на иконку шестерёнки (Настройки).
2. Найдите секцию **Streamer.bot**.
3. Включите галочку активации интеграции.
@@ -21,7 +23,7 @@
---
-## 🎯 Шаг 3. Привязка событий (Маппинги)
+## Шаг 3. Привязка событий (Маппинги)
Чтобы таймер реагировал на события, нужно создать для них "Привязки" (Event -> Сумма). Нажмите **+ Добавить событие** в настройках Donaton Timer.
Перед вами 3 поля. Обратите внимание, что Streamer.bot **чувствителен к регистру**! Пишите `Source` и `Type` точь-в-точь как в таблицах ниже.
@@ -35,7 +37,7 @@
### Примеры популярных привязок для Twitch
| Что хотите отслеживать | Source | Type | Сумма |
-| :--- | :--- | :--- | :--- |
+| :--- | :---: | :---: | :---: |
| **Обычная Сабка (Sub)** | `Twitch` | `Sub` | `130` *(фиксированная цена)* |
| **Ресаб (ReSub)** | `Twitch` | `ReSub` | `130` *(фиксированная цена)* |
| **Подарочная сабка (GiftSub)** | `Twitch` | `GiftSub` | `130` *(фиксированная цена)* |
@@ -43,6 +45,8 @@
> **Заметка**: Если вам нужны награды за баллы канала, можете использовать `Source: Twitch`, `Type: PointRedemption`, и указать нужную фиксированную цену (эквивалент в рублях).
+---
+
### Как передать кастомные донаты (Например, из C# или макросов)
Если вы генерируете собственные кастомные события внутри Streamer.bot (например, интеграция со сторонними сервисами через код), вы можете ловить их в Donaton Timer.
@@ -50,4 +54,4 @@
* Source: `General`
* Type: `Custom`
* Сумма: `0` (чтобы передавать динамическую сумму из кода)
-2. В Streamer.bot убедитесь, что при трансляции события (Broadcasting) в объекте `data` вы отправляете поле `amount` (сумма доната) и, желательно, поле `user` или `name` (имя зрителя). Таймер автоматически подхватит эти данные.
+2. В Streamer.bot убедитесь, что при трансляции события (Broadcasting) в объект `data` вы отправляете поле `amount` (сумма доната) и, желательно, поле `user` или `name` (имя зрителя). Таймер автоматически подхватит эти данные.
diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc
index 1cf6726..c4c199e 100644
--- a/windows/runner/Runner.rc
+++ b/windows/runner/Runner.rc
@@ -93,7 +93,7 @@ BEGIN
VALUE "FileDescription", "DonatonTimer - Timer for donation marathons" "\0"
VALUE "FileVersion", VERSION_AS_STRING "\0"
VALUE "InternalName", "DonatonTimer" "\0"
- VALUE "LegalCopyright", "Copyright (C) 2025 MjKey (https://mjkey.ru). All rights reserved." "\0"
+ VALUE "LegalCopyright", "Copyright (C) 2026 MjKey (https://mjkey.ru). All rights reserved." "\0"
VALUE "OriginalFilename", "DonatonTimer.exe" "\0"
VALUE "ProductName", "DonatonTimer" "\0"
VALUE "ProductVersion", VERSION_AS_STRING "\0"
diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp
index dbbc964..318a34b 100644
--- a/windows/runner/main.cpp
+++ b/windows/runner/main.cpp
@@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
FlutterWindow window(project);
Win32Window::Point origin(10, 10);
Win32Window::Size size(1280, 720);
- if (!window.Create(L"DonatonTimer v3.0.5 by MjKey", origin, size)) {
+ if (!window.Create(L"DonatonTimer v3.0.6 by MjKey", origin, size)) {
return EXIT_FAILURE;
}
window.SetQuitOnClose(true);