From 54f0385be4b96ecc2eae62b2aa1f050f3b02e852 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 11 Jan 2026 16:59:33 -0800 Subject: [PATCH 001/126] merge instructions into `AGENTS.md` --- .github/copilot-instructions.md | 216 -------------------------------- AGENTS.md | 131 +++++++++++++++++++ CLAUDE.md | 125 +----------------- 3 files changed, 132 insertions(+), 340 deletions(-) delete mode 100644 .github/copilot-instructions.md create mode 100644 AGENTS.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 744582f7..00000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,216 +0,0 @@ -# Copilot Instructions for Frosty - -## Project Overview - -Frosty is a cross-platform Flutter app for Twitch that supports third-party emotes (7TV, BTTV, FFZ). The architecture is built around **MobX state management** with strict code generation requirements and a **shared HTTP client pattern** for API efficiency. - -## Critical Development Workflow - -### Essential Commands - -**Dependencies and Development:** - -- `flutter pub get` - Install dependencies -- `flutter run` - Run the app on connected device/emulator -- `flutter build apk` - Build Android APK -- `flutter build ios` - Build iOS app -- `flutter analyze` - Run static analysis and lint checks -- `flutter test` - Run all tests - -### Code Generation (REQUIRED) - -All MobX stores and JSON models require code generation. **Always run after any MobX or model changes:** - -```bash -dart run build_runner build --delete-conflicting-outputs -``` - -**Alternative commands:** - -- `dart run build_runner build` - Generate code once -- `dart run build_runner watch` - Watch and regenerate code on changes - -Generated `.g.dart` files are excluded from linting but must be committed to source control. - -### Environment Setup - -Twitch API requires client credentials via `--dart-define`: - -```bash -flutter run --dart-define=clientId=YOUR_TWITCH_CLIENT_ID --dart-define=secret=YOUR_TWITCH_CLIENT_SECRET -``` - -## Architecture Patterns - -### State Management (MobX) - -- **Store Pattern**: Each feature has stores in `lib/screens/{feature}/stores/` (not at screen root) -- **Naming**: All stores end with `Store` suffix and have corresponding `.g.dart` files -- **Global Stores**: - - `AuthStore`, `SettingsStore` in `lib/screens/settings/stores/` - - `GlobalAssetsStore` (shared emote/badge cache) in `lib/stores/` -- **Dependency Injection**: Stores are provided via Provider in `main.dart` with shared HTTP client - -### API Service Architecture - -- **Shared HTTP Client**: Single Dio instance configured in `DioClient.createClient()` with: - - Connection pooling and keep-alive headers for efficiency - - Optimized timeouts: 8s connect, 15s receive, 10s send - - Global Twitch User-Agent header - - Simple logging in debug mode (not verbose to avoid log spam) -- **API Services**: `TwitchApi`, `BTTVApi`, `FFZApi`, `SevenTVApi` in `lib/apis/` -- **Authentication Interceptor**: `TwitchAuthInterceptor` automatically injects auth headers: - - Detects Twitch API URLs and adds Authorization + Client-Id headers - - Updates automatically when user authentication changes - - Eliminates manual header passing throughout the codebase -- **Error Handling**: `UnauthorizedInterceptor` catches 401 errors for token refresh -- **Service Pattern**: All API services extend `BaseApiClient` for consistent error handling - -### Project Structure - -**Key Directories:** - -- `lib/screens/` - UI screens organized by feature (home, channel, settings, onboarding) - - Each screen has its own MobX stores in a `stores/` subdirectory -- `lib/models/` - Data models with JSON serialization (uses `.g.dart` files) -- `lib/apis/` - API services for Twitch, BTTV, FFZ, and 7TV -- `lib/widgets/` - Reusable UI components -- `lib/cache_manager.dart` - Custom cache management for images/media -- `lib/utils.dart` - Utility functions and helpers -- `lib/utils/` - Additional utility modules including context extensions - -### Screen Organization - -``` -lib/screens/{feature}/ -├── {feature}.dart # Main UI -├── stores/ # Feature stores directory -│ ├── {feature}_store.dart -│ └── {feature}_store.g.dart -└── subdirectories/ # Feature sub-components -``` - -**Main Application Flow:** - -1. App starts in `main.dart` with Firebase initialization and dependency injection via Provider -2. Authentication handled by `AuthStore` using Twitch OAuth -3. Settings persisted via `SettingsStore` with SharedPreferences -4. API services inject common HTTP client for efficient connection reuse - -### Model Generation - -- **JSON Serialization**: Models use `@JsonSerializable()` with `.g.dart` companions -- **Emote Architecture**: Base `Emote` class with platform-specific factories (`Emote.fromTwitch()`, `Emote.fromBTTV()`, `Emote.fromFFZ()`, `Emote.from7TV()`) - -## Key Integration Points - -### Firebase Services - -Initialized in `main.dart` with error handling: - -- Crashlytics for uncaught errors -- Performance monitoring -- Analytics - -### Chat System - -- **IRC Connection**: WebSocket to Twitch IRC for real-time chat -- **Emote Loading**: Asynchronous third-party emote fetching (BTTV/FFZ/7TV) -- **Message Parsing**: Custom rendering with emote/badge support -- **Real-time IRC**: WebSocket connection to Twitch IRC with custom `IRCMessage` parsing -- **Third-party Emotes**: Asynchronous loading of BTTV, FFZ, and 7TV assets via dedicated APIs -- **Message Management**: 5000 message limit with 20% batch removal optimization for performance -- **Assets Store**: Separate `ChatAssetsStore` manages emotes, badges, and chat-related data -- **User interaction features**: Blocking, reporting, moderation capabilities - -### Authentication Flow - -- **OAuth**: Twitch OAuth via WebView in `AuthStore` -- **Token Storage**: FlutterSecureStorage for secure token persistence -- **Auto-refresh**: Token validation and refresh logic in `AuthStore` -- **Two-tier Token System**: - - Default app token for unauthenticated requests - - Optional user token stored in Flutter Secure Storage - - Automatic token validation on startup with fallback -- **WebView OAuth Flow**: Custom JavaScript injection in WebView for seamless Twitch login experience without opening external browsers -- **Secure Storage Cleanup**: First-run detection clears secure storage to handle Android/iOS uninstall scenarios where secure storage persists - -## Code Style Enforcement - -Analysis rules in `analysis_options.yaml`: - -- Single quotes: `prefer_single_quotes` -- Package imports: `always_use_package_imports` -- Trailing commas: `require_trailing_commas` -- Final locals: `prefer_final_locals` -- Additional rules: `directives_ordering`, `avoid_void_async`, `always_declare_return_types`, `avoid_redundant_argument_values`, `unnecessary_parenthesis` - -**Important**: `.g.dart` files are excluded from analysis but must be committed to source control. - -## Common Patterns - -### MobX Reactions - -```dart -late final ReactionDisposer _disposeReaction; -_disposeReaction = reaction( - (_) => authStore.isLoggedIn, - (_) => _selectedIndex = 0, -); -``` - -### Settings Persistence - -Auto-saving pattern in `main.dart`: - -```dart -autorun((_) => prefs.setString('settings', jsonEncode(settingsStore))); -``` - -### Error Handling - -API services use typed exceptions via `BaseApiClient` (`lib/apis/base_api_client.dart`): -- `ApiException`, `NetworkException`, `TimeoutException`, `ServerException`, `NotFoundException`, `UnauthorizedException` - -## MobX Store Implementation - -### Store Pattern - -All stores follow `StoreBase with _$StoreName` pattern: - -- Generate `.g.dart` files with `dart run build_runner build` -- Use `@observable`, `@action`, and `@computed` annotations -- Settings store uses `@JsonSerializable()` with automatic persistence via `autorun()` - -### Authentication Headers - -Generated as `@computed` properties that automatically update when tokens change. - -### State Persistence - -Settings automatically saved to SharedPreferences via MobX `autorun()` reaction. - -## Custom Cache Management - -- **Orphaned File Cleanup**: `CustomCacheManager.removeOrphanedCacheFiles()` runs on startup to remove files not in database -- **Optimized Settings**: 30-day stale period, 10,000 max objects for efficient image caching across the app - -## Code Style Guidelines - -- **Prefer modern Flutter/Dart features and type-safety** -- **Use consistent naming, comments, and styles matching the existing codebase** -- **Single quotes, trailing commas, and final locals per lint rules** -- **Package imports over relative imports** -- **Observer widgets only wrap necessary UI components for MobX optimization** - -## Platform Support - -- **iOS/Android**: Native platform features via plugins -- **Custom Cache**: `CustomCacheManager` for media caching -- **PiP Mode**: Custom implementation via git dependency - -## Commit Convention - -Commits use lowercase, descriptive messages without prefixes (e.g., `fix landscape bottom padding in chat bottom bar`, `add swipe pip gesture support`). Keep commits tightly scoped. - -When working with this codebase, always consider MobX code generation requirements and the shared HTTP client pattern for API efficiency. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..66762750 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,131 @@ +# AGENTS.md + +## Development Workflow + +**Plan Mode**: When in plan mode, interview thoroughly before coding: +- Ask detailed questions about technical implementation, UI/UX concerns, tradeoffs, and edge cases +- Continue asking questions until all requirements and non-obvious decisions are clear +- Don't begin implementation until all important details are resolved + +**Pattern Consistency**: When implementing new code: +- Search the codebase to find existing usages and implementations +- Prefer following established patterns, styles, and practices for consistency + +## Development Commands + +**Common Commands:** +- `flutter pub get` - Install dependencies +- `flutter run --dart-define=clientId=YOUR_CLIENT_ID --dart-define=secret=YOUR_CLIENT_SECRET` - Run with Twitch API credentials +- `flutter analyze` - Run static analysis and lint checks +- `flutter test` - Run all tests +- `flutter test test/path/to/test.dart` - Run a single test file +- `flutter build apk` / `flutter build ios` - Build release binaries + +**Code Generation** (required after changing MobX stores or `@JsonSerializable` models): +- `dart run build_runner build` - Generate once +- `dart run build_runner build --delete-conflicting-outputs` - Force rebuild all +- `dart run build_runner watch` - Watch mode for development + +Generated `.g.dart` files are excluded from linting but must be committed to source control. + +## Architecture Overview + +**State Management**: MobX with code generation. All stores end with `Store` and have corresponding `.g.dart` generated files. + +**Key Directories**: +- `lib/screens/` - UI screens organized by feature (home, channel, settings, onboarding) +- `lib/models/` - Data models with JSON serialization (uses .g.dart files) +- `lib/apis/` - API services for Twitch, BTTV, FFZ, and 7TV +- `lib/widgets/` - Reusable UI components +- `lib/utils/` - Utility modules including context extensions + +**Screen Organization**: `lib/screens/{feature}/` contains `{feature}.dart` (main UI), `stores/` subdirectory with MobX stores, and feature sub-components. + +**Main Application Flow**: +1. App starts in `main.dart` with Firebase initialization and dependency injection via Provider +2. Authentication handled by `AuthStore` using Twitch OAuth +3. Settings persisted via `SettingsStore` with SharedPreferences and MobX `autorun()` +4. API services share a common Dio HTTP client for efficient connection reuse + +**Global Stores** (injected via Provider in `main.dart`): +- `AuthStore` (`lib/screens/settings/stores/`) - Authentication state and token management +- `SettingsStore` (`lib/screens/settings/stores/`) - User preferences with automatic persistence via MobX `autorun()` +- `GlobalAssetsStore` (`lib/stores/`) - Shared cache for global emotes and badges across all chat tabs + +## HTTP & API Architecture + +**Shared HTTP Client**: Single Dio instance configured in `DioClient.createClient()` with: +- Connection pooling and keep-alive headers for efficiency +- Optimized timeouts: 8s connect, 15s receive, 10s send +- Frosty User-Agent header for Twitch API compatibility + +**Authentication Interceptor**: `TwitchAuthInterceptor` automatically injects auth headers for Twitch API URLs. `UnauthorizedInterceptor` catches 401 errors for token refresh. + +**Two-tier Token System**: Default app token for unauthenticated requests + optional user token in Flutter Secure Storage. + +**BaseApiClient Pattern**: All API services (`TwitchApi`, `BTTVApi`, `FFZApi`, `SevenTVApi`) extend `BaseApiClient` which provides: +- Generic GET/POST/PUT/DELETE methods with centralized error handling +- Automatic conversion of `DioException` to typed exceptions + +**Exception Hierarchy**: `ApiException` base class with `NetworkException`, `TimeoutException`, `ServerException`, `NotFoundException`, `UnauthorizedException` (see `lib/apis/base_api_client.dart`). + +## MobX Store Implementation + +**Store Pattern**: All stores follow `StoreBase with _$StoreName` pattern: + +```dart +class SomeStore = SomeStoreBase with _$SomeStore; + +abstract class SomeStoreBase with Store { + @observable + var someValue = ''; + + @action + void updateValue(String value) => someValue = value; + + @computed + String get derivedValue => someValue.toUpperCase(); +} +``` + +**MobX Reactions** (for side effects and persistence): + +```dart +// Auto-save settings whenever they change (in main.dart) +autorun((_) => prefs.setString('settings', jsonEncode(settingsStore))); + +// React to specific state changes +reaction((_) => authStore.isLoggedIn, (_) => _selectedIndex = 0); +``` + +## Chat System Architecture + +- **Real-time IRC**: WebSocket connection to Twitch IRC with custom `IRCMessage` parsing +- **Third-party Emotes**: Asynchronous loading of BTTV, FFZ, and 7TV assets via dedicated APIs +- **Message Management**: 5000 message limit with 20% batch removal optimization +- **Assets Store**: `ChatAssetsStore` manages channel-specific emotes/badges +- **User Interaction**: Blocking, reporting, and moderation capabilities + +## Additional Patterns + +**Emote Architecture**: Base `Emote` class with platform-specific factories: `Emote.fromTwitch()`, `Emote.fromBTTV()`, `Emote.fromFFZ()`, `Emote.from7TV()`. + +**Custom Cache Manager**: Uses 30-day stale period and 10k max objects. `CustomCacheManager.removeOrphanedCacheFiles()` runs on startup to clean files not in database. + +**Secure Storage Cleanup**: First-run detection clears Flutter Secure Storage to handle Android/iOS uninstall edge case where secure storage persists but app data is wiped. + +**Authentication Flow**: +- OAuth via WebView with custom JavaScript injection for seamless Twitch login +- Token storage in FlutterSecureStorage +- Auto-refresh and validation logic in `AuthStore` + +## Code Style + +**Lint Rules** (from `analysis_options.yaml`): +- `prefer_single_quotes`, `always_use_package_imports`, `require_trailing_commas` +- `prefer_final_locals`, `prefer_final_in_for_each`, `avoid_redundant_argument_values` +- `directives_ordering`, `avoid_void_async`, `always_declare_return_types`, `unnecessary_parenthesis` + +## Commit Convention + +Commits use lowercase, descriptive messages without prefixes (e.g., `fix landscape bottom padding in chat bottom bar`, `add swipe pip gesture support`). Keep commits tightly scoped. diff --git a/CLAUDE.md b/CLAUDE.md index b9d22e4e..078c29c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,127 +2,4 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## Development Workflow - -**Plan Mode**: When in plan mode, interview thoroughly before coding: -- Ask detailed questions about technical implementation, UI/UX concerns, tradeoffs, and edge cases -- Continue asking questions until all requirements and non-obvious decisions are clear -- Don't begin implementation until all important details are resolved - -**Pattern Consistency**: When implementing new code: -- Search the codebase to find existing usages and implementations -- Prefer following established patterns, styles, and practices for consistency - -## Development Commands - -**Common Commands:** -- `flutter pub get` - Install dependencies -- `flutter run --dart-define=clientId=YOUR_CLIENT_ID --dart-define=secret=YOUR_CLIENT_SECRET` - Run with Twitch API credentials -- `flutter analyze` - Run static analysis and lint checks -- `flutter test` - Run all tests -- `flutter build apk` / `flutter build ios` - Build release binaries - -**Code Generation** (required after changing MobX stores or `@JsonSerializable` models): -- `dart run build_runner build` - Generate once -- `dart run build_runner build --delete-conflicting-outputs` - Force rebuild all -- `dart run build_runner watch` - Watch mode for development - -## Architecture Overview - -**State Management**: MobX with code generation. All stores end with `Store` and have corresponding `.g.dart` generated files. - -**Key Directories**: - -- `lib/screens/` - UI screens organized by feature (home, channel, settings, onboarding) - - Each screen has its own MobX stores in a `stores/` subdirectory -- `lib/models/` - Data models with JSON serialization (uses .g.dart files) -- `lib/apis/` - API services for Twitch, BTTV, FFZ, and 7TV -- `lib/widgets/` - Reusable UI components -- `lib/utils/` - Utility modules including context extensions - -**Main Application Flow**: - -1. App starts in `main.dart` with Firebase initialization and dependency injection via Provider -2. Authentication handled by `AuthStore` using Twitch OAuth -3. Settings persisted via `SettingsStore` with SharedPreferences and MobX `autorun()` -4. API services share a common Dio HTTP client for efficient connection reuse - -**Global Stores** (injected via Provider in `main.dart`): - -- `AuthStore` (`lib/screens/settings/stores/`) - Authentication state and token management -- `SettingsStore` (`lib/screens/settings/stores/`) - User preferences with automatic persistence via MobX `autorun()` -- `GlobalAssetsStore` (`lib/stores/`) - Shared cache for global emotes and badges across all chat tabs - -**HTTP & Auth**: Centralized Dio client (`DioClient.createClient()`) with connection pooling and optimized timeouts. `TwitchAuthInterceptor` auto-injects auth headers for Twitch API URLs. `UnauthorizedInterceptor` catches 401 errors for token refresh. Two-tier token system: default app token + optional user token in Flutter Secure Storage. - -## MobX Store Implementation - -**Store Pattern**: All stores follow `StoreBase with _$StoreName` pattern: - -```dart -class SomeStore = SomeStoreBase with _$SomeStore; - -abstract class SomeStoreBase with Store { - @observable - var someValue = ''; - - @action - void updateValue(String value) => someValue = value; - - @computed - String get derivedValue => someValue.toUpperCase(); -} -``` - -**MobX Reactions** (for side effects and persistence): - -```dart -// Auto-save settings whenever they change (in main.dart) -autorun((_) => prefs.setString('settings', jsonEncode(settingsStore))); - -// React to specific state changes -reaction((_) => authStore.isLoggedIn, (_) => _selectedIndex = 0); -``` - -## Chat System Architecture - -- **Real-time IRC**: WebSocket connection to Twitch IRC with custom `IRCMessage` parsing -- **Third-party Emotes**: Asynchronous loading of BTTV, FFZ, and 7TV assets via dedicated APIs -- **Message Management**: 5000 message limit with 20% batch removal optimization -- **Assets Store**: `ChatAssetsStore` manages channel-specific emotes/badges - -## Additional Patterns - -**Emote Architecture**: Base `Emote` class with platform-specific factories: `Emote.fromTwitch()`, `Emote.fromBTTV()`, `Emote.fromFFZ()`, `Emote.from7TV()`. - -**Custom Cache Manager**: Uses 30-day stale period and 10k max objects. `CustomCacheManager.removeOrphanedCacheFiles()` runs on startup to clean files not in database. - -**Secure Storage Cleanup**: First-run detection clears Flutter Secure Storage to handle Android/iOS uninstall edge case where secure storage persists but app data is wiped. - -## API Architecture - -**BaseApiClient Pattern**: All API services (`TwitchApi`, `BTTVApi`, `FFZApi`, `SevenTVApi`) extend `BaseApiClient` which provides: -- Generic GET/POST/PUT/DELETE methods with centralized error handling -- Automatic conversion of `DioException` to typed exceptions - -**Exception Hierarchy** (in `lib/apis/base_api_client.dart`): -- `ApiException` - Base class for all API errors -- `NetworkException` - Connection errors (no internet) -- `TimeoutException` - Request timeouts -- `ServerException` - 5xx server errors -- `NotFoundException` - 404 errors -- `UnauthorizedException` - 401 errors (triggers token refresh) - -## Code Style - -**Lint Rules** (from `analysis_options.yaml`): -- `prefer_single_quotes`, `always_use_package_imports`, `require_trailing_commas` -- `prefer_final_locals`, `prefer_final_in_for_each`, `avoid_redundant_argument_values` -- `directives_ordering`, `avoid_void_async`, `always_declare_return_types`, `unnecessary_parenthesis` - -**Other**: -- Generated `.g.dart` files must be committed alongside source changes - -## Commit Convention - -Commits use lowercase, descriptive messages without prefixes (e.g., `fix landscape bottom padding in chat bottom bar`, `add swipe pip gesture support`). Keep commits tightly scoped. +@AGENTS.md From e58c9875e05f53b394e42a36f4fa670b4f6e655c Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sat, 7 Feb 2026 00:46:01 -0800 Subject: [PATCH 002/126] update ai files --- .claude/settings.json | 8 ++++---- AGENTS.md | 29 ++++++++++++++++++++++++----- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 1e319a9a..f2671057d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -2,12 +2,12 @@ "enabledPlugins": { "frontend-design@claude-plugins-official": true, "feature-dev@claude-plugins-official": true, - "code-review@claude-plugins-official": true, "commit-commands@claude-plugins-official": true, - "security-guidance@claude-plugins-official": true, - "typescript-lsp@claude-plugins-official": true, "pr-review-toolkit@claude-plugins-official": true, - "code-simplifier@claude-plugins-official": true + "code-simplifier@claude-plugins-official": true, + "typescript-lsp@claude-plugins-official": true, + "claude-md-management@claude-plugins-official": true, + "claude-code-setup@claude-plugins-official": true }, "outputStyle": "Explanatory" } diff --git a/AGENTS.md b/AGENTS.md index 66762750..1da9fe22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,12 @@ - Search the codebase to find existing usages and implementations - Prefer following established patterns, styles, and practices for consistency +**Dead Code Cleanup**: When new code replaces or supersedes existing functionality: +- Identify and remove anything the new code makes redundant +- Clean up any remaining references or imports to the removed code + +**Parallelization**: Favor parallel tool calls and subagents whenever tasks are independent (e.g., reading multiple files, running searches, exploring different parts of the codebase). Don't do things sequentially when they can be done concurrently. + ## Development Commands **Common Commands:** @@ -26,7 +32,7 @@ - `dart run build_runner build --delete-conflicting-outputs` - Force rebuild all - `dart run build_runner watch` - Watch mode for development -Generated `.g.dart` files are excluded from linting but must be committed to source control. +Generated `.g.dart` files are excluded from linting but must be committed to source control. Never edit `.g.dart` files directly — they are overwritten by build_runner. ## Architecture Overview @@ -38,6 +44,7 @@ Generated `.g.dart` files are excluded from linting but must be committed to sou - `lib/apis/` - API services for Twitch, BTTV, FFZ, and 7TV - `lib/widgets/` - Reusable UI components - `lib/utils/` - Utility modules including context extensions +- `lib/services/` - Application services (e.g., shared timer service) **Screen Organization**: `lib/screens/{feature}/` contains `{feature}.dart` (main UI), `stores/` subdirectory with MobX stores, and feature sub-components. @@ -50,6 +57,7 @@ Generated `.g.dart` files are excluded from linting but must be committed to sou **Global Stores** (injected via Provider in `main.dart`): - `AuthStore` (`lib/screens/settings/stores/`) - Authentication state and token management - `SettingsStore` (`lib/screens/settings/stores/`) - User preferences with automatic persistence via MobX `autorun()` +- `UserStore` (`lib/screens/settings/stores/`) - Current user profile data - `GlobalAssetsStore` (`lib/stores/`) - Shared cache for global emotes and badges across all chat tabs ## HTTP & API Architecture @@ -121,10 +129,21 @@ reaction((_) => authStore.isLoggedIn, (_) => _selectedIndex = 0); ## Code Style -**Lint Rules** (from `analysis_options.yaml`): -- `prefer_single_quotes`, `always_use_package_imports`, `require_trailing_commas` -- `prefer_final_locals`, `prefer_final_in_for_each`, `avoid_redundant_argument_values` -- `directives_ordering`, `avoid_void_async`, `always_declare_return_types`, `unnecessary_parenthesis` +**Common lint mistakes to avoid:** +- Always use package imports, not relative imports (`import 'package:frosty/...'`) +- Always include trailing commas in argument lists and widget trees +- Use single quotes for strings + +Full lint rules are enforced by `flutter analyze` — run it after making changes to catch issues early. + +## Testing + +Tests live in `test/` mirroring the `lib/` structure: +- `test/models/` - Model unit tests (badges, emotes, IRC parsing) +- `test/fixtures/` - Shared test data (e.g., IRC message fixtures) +- `test/regex_test.dart`, `test/utils_test.dart` - Utility tests + +Run with `flutter test` or `flutter test test/path/to/file.dart` for a single file. ## Commit Convention From baebafea59943db96e0c19e77aca688039b509da Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sat, 7 Feb 2026 00:51:22 -0800 Subject: [PATCH 003/126] overhaul tests --- pubspec.lock | 24 ++ pubspec.yaml | 2 + test/apis/base_api_client_test.dart | 403 ++++++++++++++++++ test/apis/bttv_api_test.dart | 106 +++++ test/apis/ffz_api_test.dart | 192 +++++++++ test/apis/seventv_api_test.dart | 142 ++++++ test/apis/twitch_api_test.dart | 392 +++++++++++++++++ test/apis/twitch_auth_interceptor_test.dart | 95 +++++ test/fixtures/api_responses.dart | 350 +++++++++++++++ test/fixtures/irc_messages.dart | 14 + test/models/category_test.dart | 55 +++ test/models/channel_test.dart | 49 +++ test/models/chatters_test.dart | 36 ++ test/models/emotes_test.dart | 119 ++++-- test/models/events_test.dart | 110 +++++ test/models/irc_test.dart | 134 +++++- test/models/shared_chat_session_test.dart | 41 ++ test/models/stream_test.dart | 45 ++ test/models/user_test.dart | 44 ++ test/regex_test.dart | 13 +- .../settings/stores/settings_store_test.dart | 278 ++++++++++++ test/utils_test.dart | 45 ++ 22 files changed, 2637 insertions(+), 52 deletions(-) create mode 100644 test/apis/base_api_client_test.dart create mode 100644 test/apis/bttv_api_test.dart create mode 100644 test/apis/ffz_api_test.dart create mode 100644 test/apis/seventv_api_test.dart create mode 100644 test/apis/twitch_api_test.dart create mode 100644 test/apis/twitch_auth_interceptor_test.dart create mode 100644 test/fixtures/api_responses.dart create mode 100644 test/models/category_test.dart create mode 100644 test/models/channel_test.dart create mode 100644 test/models/chatters_test.dart create mode 100644 test/models/events_test.dart create mode 100644 test/models/shared_chat_session_test.dart create mode 100644 test/models/stream_test.dart create mode 100644 test/models/user_test.dart create mode 100644 test/screens/settings/stores/settings_store_test.dart diff --git a/pubspec.lock b/pubspec.lock index 599ea649..9d4a2ad6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -608,6 +608,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.6.0" + http_mock_adapter: + dependency: "direct dev" + description: + name: http_mock_adapter + sha256: "46399c78bd4a0af071978edd8c502d7aeeed73b5fb9860bca86b5ed647a63c1b" + url: "https://pub.dev" + source: hosted + version: "0.6.1" http_multi_server: dependency: transitive description: @@ -720,6 +728,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + logger: + dependency: transitive + description: + name: logger + sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 + url: "https://pub.dev" + source: hosted + version: "2.6.2" logging: dependency: transitive description: @@ -784,6 +800,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.4" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" + source: hosted + version: "1.0.4" native_device_orientation: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 51ec65a3..3be38265 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -87,6 +87,8 @@ dev_dependencies: mobx_codegen: ^2.0.6 flutter_native_splash: ^2.0.5 flutter_launcher_icons: ^0.14.1 + mocktail: ^1.0.4 + http_mock_adapter: ^0.6.1 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/apis/base_api_client_test.dart b/test/apis/base_api_client_test.dart new file mode 100644 index 00000000..258d68dd --- /dev/null +++ b/test/apis/base_api_client_test.dart @@ -0,0 +1,403 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/base_api_client.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; + +/// Concrete test subclass to test BaseApiClient through its public interface. +class TestApiClient extends BaseApiClient { + TestApiClient(Dio dio) : super(dio, 'https://api.test.com'); + + /// Test wrapper that exercises the error handling path through get(). + Future testGet(String endpoint) => get(endpoint); +} + +void main() { + late Dio dio; + late DioAdapter dioAdapter; + late TestApiClient client; + + setUp(() { + dio = Dio(BaseOptions()); + dioAdapter = DioAdapter(dio: dio); + client = TestApiClient(dio); + }); + + group('isSuccessResponse', () { + test('returns true for 200', () { + final response = Response( + requestOptions: RequestOptions(), + statusCode: 200, + ); + expect(client.isSuccessResponse(response), isTrue); + }); + + test('returns true for 201', () { + final response = Response( + requestOptions: RequestOptions(), + statusCode: 201, + ); + expect(client.isSuccessResponse(response), isTrue); + }); + + test('returns true for 204', () { + final response = Response( + requestOptions: RequestOptions(), + statusCode: 204, + ); + expect(client.isSuccessResponse(response), isTrue); + }); + + test('returns false for 300', () { + final response = Response( + requestOptions: RequestOptions(), + statusCode: 300, + ); + expect(client.isSuccessResponse(response), isFalse); + }); + + test('returns false for 400', () { + final response = Response( + requestOptions: RequestOptions(), + statusCode: 400, + ); + expect(client.isSuccessResponse(response), isFalse); + }); + + test('returns false for 500', () { + final response = Response( + requestOptions: RequestOptions(), + statusCode: 500, + ); + expect(client.isSuccessResponse(response), isFalse); + }); + + test('returns false for null statusCode', () { + final response = Response(requestOptions: RequestOptions()); + expect(client.isSuccessResponse(response), isFalse); + }); + }); + + group('error handling through get()', () { + test('400 throws ApiException', () async { + dioAdapter.onGet( + 'https://api.test.com/test', + (server) => server.reply(400, {'message': 'Bad field'}), + ); + + expect( + () => client.testGet('/test'), + throwsA( + isA().having((e) => e.message, 'message', 'Bad field'), + ), + ); + }); + + test('401 throws UnauthorizedException', () async { + dioAdapter.onGet( + 'https://api.test.com/auth', + (server) => server.reply(401, {'message': 'Unauthorized'}), + ); + + expect( + () => client.testGet('/auth'), + throwsA(isA()), + ); + }); + + test('404 throws NotFoundException', () async { + dioAdapter.onGet( + 'https://api.test.com/missing', + (server) => server.reply(404, {'message': 'User not found'}), + ); + + expect( + () => client.testGet('/missing'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'User not found', + ), + ), + ); + }); + + test('404 with no server message uses default', () async { + dioAdapter.onGet( + 'https://api.test.com/empty404', + (server) => server.reply(404, 'not json'), + ); + + expect( + () => client.testGet('/empty404'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Resource not found', + ), + ), + ); + }); + + test('409 throws ApiException about conflict', () async { + dioAdapter.onGet( + 'https://api.test.com/conflict', + (server) => server.reply(409, null), + ); + + expect( + () => client.testGet('/conflict'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Conflict'), + ), + ), + ); + }); + + test('429 throws ApiException about rate limit', () async { + dioAdapter.onGet( + 'https://api.test.com/ratelimit', + (server) => server.reply(429, null), + ); + + expect( + () => client.testGet('/ratelimit'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Rate limit'), + ), + ), + ); + }); + + test('500 throws ServerException', () async { + dioAdapter.onGet( + 'https://api.test.com/server-error', + (server) => server.reply(500, null), + ); + + expect( + () => client.testGet('/server-error'), + throwsA( + isA().having( + (e) => e.statusCode, + 'statusCode', + 500, + ), + ), + ); + }); + + test('502 throws ServerException', () async { + dioAdapter.onGet( + 'https://api.test.com/bad-gateway', + (server) => server.reply(502, null), + ); + + expect( + () => client.testGet('/bad-gateway'), + throwsA( + isA().having( + (e) => e.statusCode, + 'statusCode', + 502, + ), + ), + ); + }); + + test('503 throws ServerException', () async { + dioAdapter.onGet( + 'https://api.test.com/maintenance', + (server) => server.reply(503, null), + ); + + expect( + () => client.testGet('/maintenance'), + throwsA(isA()), + ); + }); + + test('504 throws ServerException', () async { + dioAdapter.onGet( + 'https://api.test.com/timeout', + (server) => server.reply(504, null), + ); + + expect( + () => client.testGet('/timeout'), + throwsA(isA()), + ); + }); + + test('422 uses server message from error field', () async { + dioAdapter.onGet( + 'https://api.test.com/unprocessable', + (server) => server.reply(422, {'error': 'Invalid email'}), + ); + + expect( + () => client.testGet('/unprocessable'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Invalid email', + ), + ), + ); + }); + + test('400 uses error_description field', () async { + dioAdapter.onGet( + 'https://api.test.com/oauth-error', + (server) => server.reply( + 400, + {'error_description': 'OAuth error description'}, + ), + ); + + expect( + () => client.testGet('/oauth-error'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'OAuth error description', + ), + ), + ); + }); + + test('400 prefers message field over error field', () async { + dioAdapter.onGet( + 'https://api.test.com/priority', + (server) => server.reply(400, { + 'message': 'From message', + 'error': 'From error', + }), + ); + + expect( + () => client.testGet('/priority'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'From message', + ), + ), + ); + }); + + test('403 returns permission error', () async { + dioAdapter.onGet( + 'https://api.test.com/forbidden', + (server) => server.reply(403, null), + ); + + expect( + () => client.testGet('/forbidden'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('permissions'), + ), + ), + ); + }); + + test('unknown status code throws ServerException', () async { + dioAdapter.onGet( + 'https://api.test.com/teapot', + (server) => server.reply(418, null), + ); + + expect( + () => client.testGet('/teapot'), + throwsA( + isA().having( + (e) => e.statusCode, + 'statusCode', + 418, + ), + ), + ); + }); + }); + + group('successful get()', () { + test('returns data on success', () async { + dioAdapter.onGet( + 'https://api.test.com/users', + (server) => server.reply(200, {'data': 'test'}), + ); + + final result = await client.testGet('/users'); + expect(result, {'data': 'test'}); + }); + + test('handles absolute URL', () async { + dioAdapter.onGet( + 'https://other.com/endpoint', + (server) => server.reply(200, {'result': 'ok'}), + ); + + final result = await client.testGet('https://other.com/endpoint'); + expect(result, {'result': 'ok'}); + }); + }); + + group('ApiException', () { + test('toString includes message', () { + const exception = ApiException('Test error'); + expect(exception.toString(), 'ApiException: Test error'); + }); + + test('stores status code and type', () { + const exception = ApiException( + 'Error', + 404, + DioExceptionType.badResponse, + ); + expect(exception.statusCode, 404); + expect(exception.type, DioExceptionType.badResponse); + }); + }); + + group('Exception subtypes', () { + test('NetworkException has connectionError type', () { + const exception = NetworkException('No network'); + expect(exception.type, DioExceptionType.connectionError); + expect(exception.statusCode, isNull); + }); + + test('TimeoutException has connectionTimeout type', () { + const exception = TimeoutException('Timed out'); + expect(exception.type, DioExceptionType.connectionTimeout); + }); + + test('ServerException has badResponse type and status code', () { + const exception = ServerException('Server error', 500); + expect(exception.type, DioExceptionType.badResponse); + expect(exception.statusCode, 500); + }); + + test('NotFoundException has 404 status code', () { + const exception = NotFoundException('Not found'); + expect(exception.statusCode, 404); + }); + + test('UnauthorizedException has 401 status code', () { + const exception = UnauthorizedException('Unauthorized'); + expect(exception.statusCode, 401); + }); + }); +} diff --git a/test/apis/bttv_api_test.dart b/test/apis/bttv_api_test.dart new file mode 100644 index 00000000..e78004be --- /dev/null +++ b/test/apis/bttv_api_test.dart @@ -0,0 +1,106 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/apis/bttv_api.dart'; +import 'package:frosty/models/emotes.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; + +import '../fixtures/api_responses.dart'; + +void main() { + late Dio dio; + late DioAdapter dioAdapter; + late BTTVApi api; + + setUp(() { + dio = Dio(BaseOptions()); + dioAdapter = DioAdapter(dio: dio); + api = BTTVApi(dio); + }); + + group('getEmotesGlobal', () { + test('returns list of global BTTV emotes', () async { + dioAdapter.onGet( + 'https://api.betterttv.net/3/cached/emotes/global', + (server) => server.reply(200, bttvEmotesGlobalResponse), + ); + + final emotes = await api.getEmotesGlobal(); + + expect(emotes, hasLength(2)); + expect(emotes[0].name, 'SourPls'); + expect(emotes[0].type, EmoteType.bttvGlobal); + expect(emotes[0].url, contains('bttv1')); + expect(emotes[1].name, 'catJAM'); + }); + + test('returns empty list for empty response', () async { + dioAdapter.onGet( + 'https://api.betterttv.net/3/cached/emotes/global', + (server) => server.reply(200, []), + ); + + final emotes = await api.getEmotesGlobal(); + + expect(emotes, isEmpty); + }); + }); + + group('getEmotesChannel', () { + test('combines channel and shared emotes', () async { + dioAdapter.onGet( + 'https://api.betterttv.net/3/cached/users/twitch/12345', + (server) => server.reply(200, bttvEmotesChannelResponse), + ); + + final emotes = await api.getEmotesChannel(id: '12345'); + + expect(emotes, hasLength(3)); + expect(emotes[0].name, 'ChannelEmote'); + expect(emotes[0].type, EmoteType.bttvChannel); + expect(emotes[1].name, 'SharedEmote'); + expect(emotes[1].type, EmoteType.bttvShared); + expect(emotes[2].name, 'SharedEmote2'); + expect(emotes[2].type, EmoteType.bttvShared); + }); + + test('throws on 404 for unknown channel', () { + dioAdapter.onGet( + 'https://api.betterttv.net/3/cached/users/twitch/99999', + (server) => server.reply(404, {'message': 'user not found'}), + ); + + expect( + () => api.getEmotesChannel(id: '99999'), + throwsA(isA()), + ); + }); + }); + + group('getBadges', () { + test('returns map of userId to ChatBadge', () async { + dioAdapter.onGet( + 'https://api.betterttv.net/3/cached/badges', + (server) => server.reply(200, bttvBadgesResponse), + ); + + final badges = await api.getBadges(); + + expect(badges, hasLength(2)); + expect(badges['user123']!.name, 'BTTV Developer'); + expect(badges['user123']!.url, contains('dev.svg')); + expect(badges['user456']!.name, 'BTTV Supporter'); + }); + + test('returns empty map for empty response', () async { + dioAdapter.onGet( + 'https://api.betterttv.net/3/cached/badges', + (server) => server.reply(200, []), + ); + + final badges = await api.getBadges(); + + expect(badges, isEmpty); + }); + }); +} diff --git a/test/apis/ffz_api_test.dart b/test/apis/ffz_api_test.dart new file mode 100644 index 00000000..c33ca470 --- /dev/null +++ b/test/apis/ffz_api_test.dart @@ -0,0 +1,192 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/apis/ffz_api.dart'; +import 'package:frosty/models/badges.dart'; +import 'package:frosty/models/emotes.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; + +import '../fixtures/api_responses.dart'; + +void main() { + late Dio dio; + late DioAdapter dioAdapter; + late FFZApi api; + + setUp(() { + dio = Dio(BaseOptions()); + dioAdapter = DioAdapter(dio: dio); + api = FFZApi(dio); + }); + + group('getEmotesGlobal', () { + test('returns global FFZ emotes from default sets', () async { + dioAdapter.onGet( + 'https://api.frankerfacez.com/v1/set/global', + (server) => server.reply(200, ffzEmotesGlobalResponse), + ); + + final emotes = await api.getEmotesGlobal(); + + expect(emotes, hasLength(1)); + expect(emotes.first.name, 'LULW'); + expect(emotes.first.type, EmoteType.ffzGlobal); + expect(emotes.first.ownerDisplayName, 'FFZOwner'); + expect(emotes.first.ownerUsername, 'ffzowner'); + }); + + test('returns emotes from multiple default sets', () async { + dioAdapter.onGet( + 'https://api.frankerfacez.com/v1/set/global', + (server) => server.reply(200, { + 'default_sets': [1, 2], + 'sets': { + '1': { + 'emoticons': [ + { + 'name': 'LULW', + 'height': 28, + 'width': 28, + 'owner': {'display_name': 'A', 'name': 'a'}, + 'urls': {'1': 'https://cdn/1x.png'}, + 'animated': null, + }, + ], + }, + '2': { + 'emoticons': [ + { + 'name': 'KEKW', + 'height': 32, + 'width': 32, + 'owner': {'display_name': 'B', 'name': 'b'}, + 'urls': {'1': 'https://cdn/kekw.png'}, + 'animated': null, + }, + ], + }, + }, + }), + ); + + final emotes = await api.getEmotesGlobal(); + + expect(emotes, hasLength(2)); + expect(emotes[0].name, 'LULW'); + expect(emotes[1].name, 'KEKW'); + }); + }); + + group('getRoomInfo', () { + test('returns room info and channel emotes', () async { + dioAdapter.onGet( + 'https://api.frankerfacez.com/v1/room/id/12345', + (server) => server.reply(200, ffzRoomInfoResponse), + ); + + final (roomInfo, emotes) = await api.getRoomInfo(id: '12345'); + + expect(roomInfo.set, 123); + expect(roomInfo.vipBadge, isNull); + expect(roomInfo.modUrls, isNull); + expect(emotes, hasLength(1)); + expect(emotes.first.name, 'ChannelFFZ'); + expect(emotes.first.type, EmoteType.ffzChannel); + }); + + test('returns room info with VIP badge', () async { + dioAdapter.onGet( + 'https://api.frankerfacez.com/v1/room/id/54321', + (server) => server.reply(200, { + 'room': { + 'set': 456, + 'vip_badge': { + '1': 'https://cdn/vip/1x.png', + '2': 'https://cdn/vip/2x.png', + '4': 'https://cdn/vip/4x.png', + }, + 'mod_urls': null, + }, + 'sets': { + '456': { + 'emoticons': [], + }, + }, + }), + ); + + final (roomInfo, emotes) = await api.getRoomInfo(id: '54321'); + + expect(roomInfo.vipBadge, isNotNull); + expect(roomInfo.vipBadge!.url1x, 'https://cdn/vip/1x.png'); + expect(emotes, isEmpty); + }); + + test('throws NotFoundException for unknown channel', () { + dioAdapter.onGet( + 'https://api.frankerfacez.com/v1/room/id/99999', + (server) => server.reply(404, {'error': 'No such room'}), + ); + + expect( + () => api.getRoomInfo(id: '99999'), + throwsA(isA()), + ); + }); + }); + + group('getBadges', () { + test('builds user-to-badge map with reversed badge order', () async { + dioAdapter.onGet( + 'https://api.frankerfacez.com/v1/badges/ids', + (server) => server.reply(200, ffzBadgesResponse), + ); + + final badges = await api.getBadges(); + + // User 22222 appears in both badge lists, so should have 2 badges. + // Reversed iteration means badge 2 (Supporter) is processed first, + // then badge 1 (Developer). + expect(badges['22222'], hasLength(2)); + expect(badges['22222']![0].name, 'FFZ Supporter'); + expect(badges['22222']![1].name, 'FFZ Developer'); + + // User 11111 only appears in badge 1 + expect(badges['11111'], hasLength(1)); + expect(badges['11111']![0].name, 'FFZ Developer'); + + // User 33333 only appears in badge 2 + expect(badges['33333'], hasLength(1)); + expect(badges['33333']![0].name, 'FFZ Supporter'); + }); + + test('badge entries have correct type', () async { + dioAdapter.onGet( + 'https://api.frankerfacez.com/v1/badges/ids', + (server) => server.reply(200, ffzBadgesResponse), + ); + + final badges = await api.getBadges(); + + for (final badgeList in badges.values) { + for (final badge in badgeList) { + expect(badge.type, BadgeType.ffz); + } + } + }); + + test('returns empty map when no badges exist', () async { + dioAdapter.onGet( + 'https://api.frankerfacez.com/v1/badges/ids', + (server) => server.reply(200, { + 'badges': [], + 'users': {}, + }), + ); + + final badges = await api.getBadges(); + + expect(badges, isEmpty); + }); + }); +} diff --git a/test/apis/seventv_api_test.dart b/test/apis/seventv_api_test.dart new file mode 100644 index 00000000..a9d90057 --- /dev/null +++ b/test/apis/seventv_api_test.dart @@ -0,0 +1,142 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/apis/seventv_api.dart'; +import 'package:frosty/models/emotes.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; + +import '../fixtures/api_responses.dart'; + +void main() { + late Dio dio; + late DioAdapter dioAdapter; + late SevenTVApi api; + + setUp(() { + dio = Dio(BaseOptions()); + dioAdapter = DioAdapter(dio: dio); + api = SevenTVApi(dio); + }); + + group('getEmotesGlobal', () { + test('returns list of global 7TV emotes', () async { + dioAdapter.onGet( + 'https://7tv.io/v3/emote-sets/global', + (server) => server.reply(200, sevenTVEmotesGlobalResponse), + ); + + final emotes = await api.getEmotesGlobal(); + + expect(emotes, hasLength(1)); + expect(emotes.first.name, 'EZ'); + expect(emotes.first.type, EmoteType.sevenTVGlobal); + expect(emotes.first.url, contains('7tv.app')); + expect(emotes.first.url, contains('4x.webp')); + expect(emotes.first.ownerDisplayName, 'Creator'); + expect(emotes.first.ownerUsername, 'creator'); + }); + + test('returns empty list for empty emotes', () async { + dioAdapter.onGet( + 'https://7tv.io/v3/emote-sets/global', + (server) => server.reply(200, { + 'emotes': [], + }), + ); + + final emotes = await api.getEmotesGlobal(); + + expect(emotes, isEmpty); + }); + }); + + group('getEmotesChannel', () { + test('returns emote set ID and channel emotes', () async { + dioAdapter.onGet( + 'https://7tv.io/v3/users/twitch/12345', + (server) => server.reply(200, sevenTVEmotesChannelResponse), + ); + + final (emoteSetId, emotes) = await api.getEmotesChannel(id: '12345'); + + expect(emoteSetId, 'set_abc123'); + expect(emotes, hasLength(1)); + expect(emotes.first.name, 'Clap'); + expect(emotes.first.type, EmoteType.sevenTVChannel); + }); + + test('filters out emotes with empty URLs', () async { + dioAdapter.onGet( + 'https://7tv.io/v3/users/twitch/12345', + (server) => server.reply(200, { + 'emote_set': { + 'id': 'set_filter', + 'emotes': [ + { + 'id': 'valid', + 'name': 'ValidEmote', + 'data': { + 'id': 'valid', + 'name': 'ValidEmote', + 'flags': 0, + 'owner': null, + 'host': { + 'url': '//cdn.7tv.app/emote/valid', + 'files': [ + { + 'name': '1x.webp', + 'width': 32, + 'height': 32, + 'format': 'WEBP', + }, + ], + }, + }, + }, + { + 'id': 'empty_url', + 'name': 'NoUrlEmote', + 'data': { + 'id': 'empty_url', + 'name': 'NoUrlEmote', + 'flags': 0, + 'owner': null, + 'host': { + 'url': '//cdn.7tv.app/emote/empty_url', + // Only AVIF files — these get filtered by lastWhereOrNull, + // resulting in empty URL + 'files': [ + { + 'name': '1x.avif', + 'width': 32, + 'height': 32, + 'format': 'AVIF', + }, + ], + }, + }, + }, + ], + }, + }), + ); + + final (_, emotes) = await api.getEmotesChannel(id: '12345'); + + expect(emotes, hasLength(1)); + expect(emotes.first.name, 'ValidEmote'); + }); + + test('throws on 404 for unknown user', () { + dioAdapter.onGet( + 'https://7tv.io/v3/users/twitch/99999', + (server) => server.reply(404, {'message': 'Unknown User'}), + ); + + expect( + () => api.getEmotesChannel(id: '99999'), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/apis/twitch_api_test.dart b/test/apis/twitch_api_test.dart new file mode 100644 index 00000000..5b5df81a --- /dev/null +++ b/test/apis/twitch_api_test.dart @@ -0,0 +1,392 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/models/emotes.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; + +import '../fixtures/api_responses.dart'; + +void main() { + late Dio dio; + late DioAdapter dioAdapter; + late TwitchApi api; + + setUp(() { + dio = Dio(BaseOptions()); + dioAdapter = DioAdapter(dio: dio); + api = TwitchApi(dio); + }); + + group('getEmotesGlobal', () { + test('returns list of global Twitch emotes', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/chat/emotes/global', + (server) => server.reply(200, twitchEmotesGlobalResponse), + ); + + final emotes = await api.getEmotesGlobal(); + + expect(emotes, hasLength(2)); + expect(emotes[0].name, 'Kappa'); + expect(emotes[0].type, EmoteType.twitchGlobal); + expect(emotes[0].url, contains('25')); + expect(emotes[1].name, 'Keepo'); + }); + }); + + group('getBadgesGlobal', () { + test('returns map of badge set/version to ChatBadge', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/chat/badges/global', + (server) => server.reply(200, twitchBadgesGlobalResponse), + ); + + final badges = await api.getBadgesGlobal(); + + expect(badges, hasLength(1)); + expect(badges['subscriber/0'], isNotNull); + expect(badges['subscriber/0']!.name, 'Subscriber'); + expect(badges['subscriber/0']!.url, contains('4x.png')); + }); + + test('handles multiple versions per badge set', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/chat/badges/global', + (server) => server.reply(200, { + 'data': [ + { + 'set_id': 'subscriber', + 'versions': [ + { + 'id': '0', + 'image_url_1x': 'https://cdn/1x.png', + 'image_url_2x': 'https://cdn/2x.png', + 'image_url_4x': 'https://cdn/4x.png', + 'title': 'Subscriber', + 'description': '0 months', + }, + { + 'id': '3', + 'image_url_1x': 'https://cdn/3mo/1x.png', + 'image_url_2x': 'https://cdn/3mo/2x.png', + 'image_url_4x': 'https://cdn/3mo/4x.png', + 'title': '3-Month Subscriber', + 'description': '3 months', + }, + ], + }, + ], + }), + ); + + final badges = await api.getBadgesGlobal(); + + expect(badges, hasLength(2)); + expect(badges['subscriber/0']!.name, 'Subscriber'); + expect(badges['subscriber/3']!.name, '3-Month Subscriber'); + }); + }); + + group('getUser', () { + test('returns user by login', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/users', + (server) => server.reply(200, twitchUserResponse), + queryParameters: {'login': 'testuser'}, + ); + + final user = await api.getUser(userLogin: 'testuser'); + + expect(user.id, '12345'); + expect(user.login, 'testuser'); + expect(user.displayName, 'TestUser'); + expect(user.profileImageUrl, 'https://cdn/profile.png'); + }); + + test('returns user by id', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/users', + (server) => server.reply(200, twitchUserResponse), + queryParameters: {'id': '12345'}, + ); + + final user = await api.getUser(id: '12345'); + + expect(user.id, '12345'); + }); + + test('throws NotFoundException for unknown user', () { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/users', + (server) => server.reply(200, { + 'data': [], + }), + queryParameters: {'login': 'doesnotexist'}, + ); + + expect( + () => api.getUser(userLogin: 'doesnotexist'), + throwsA(isA()), + ); + }); + }); + + group('getStream', () { + test('returns stream for online user', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/streams', + (server) => server.reply(200, twitchStreamResponse), + queryParameters: {'user_login': 'testuser'}, + ); + + final stream = await api.getStream(userLogin: 'testuser'); + + expect(stream.userName, 'TestUser'); + expect(stream.gameName, 'Just Chatting'); + expect(stream.viewerCount, 1000); + }); + + test('throws ApiException for offline user', () { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/streams', + (server) => server.reply(200, { + 'data': [], + 'pagination': {}, + }), + queryParameters: {'user_login': 'offlineuser'}, + ); + + expect( + () => api.getStream(userLogin: 'offlineuser'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('offlineuser'), + ), + ), + ); + }); + }); + + group('getStreamsByIds', () { + test('builds query string for multiple user IDs', () async { + // getStreamsByIds manually builds URL: /streams?user_id=1&user_id=2&first=100 + dioAdapter.onGet( + 'https://api.twitch.tv/helix/streams?user_id=111&user_id=222&first=100', + (server) => server.reply(200, { + 'data': [ + { + 'user_id': '111', + 'user_login': 'user1', + 'user_name': 'User1', + 'game_id': '1', + 'game_name': 'Game', + 'title': 'Stream 1', + 'viewer_count': 100, + 'started_at': '2024-01-01T00:00:00Z', + 'thumbnail_url': 'https://cdn/1.jpg', + }, + { + 'user_id': '222', + 'user_login': 'user2', + 'user_name': 'User2', + 'game_id': '2', + 'game_name': 'Game 2', + 'title': 'Stream 2', + 'viewer_count': 200, + 'started_at': '2024-01-01T00:00:00Z', + 'thumbnail_url': 'https://cdn/2.jpg', + }, + ], + 'pagination': {}, + }), + ); + + final result = await api.getStreamsByIds( + userIds: ['111', '222'], + ); + + expect(result.data, hasLength(2)); + expect(result.data[0].userName, 'User1'); + expect(result.data[1].userName, 'User2'); + }); + }); + + group('validateToken', () { + test('returns true for valid token', () async { + dioAdapter.onGet( + 'https://id.twitch.tv/oauth2/validate', + (server) => server.reply(200, twitchValidateTokenResponse), + ); + + final isValid = await api.validateToken(token: 'valid_token'); + + expect(isValid, isTrue); + }); + + test('returns false for expired token (401)', () async { + dioAdapter.onGet( + 'https://id.twitch.tv/oauth2/validate', + (server) => server.reply(401, {'message': 'invalid access token'}), + ); + + final isValid = await api.validateToken(token: 'expired_token'); + + expect(isValid, isFalse); + }); + + test('rethrows on server error', () { + dioAdapter.onGet( + 'https://id.twitch.tv/oauth2/validate', + (server) => server.reply(500, null), + ); + + expect( + () => api.validateToken(token: 'some_token'), + throwsA(isA()), + ); + }); + }); + + group('getUserBlockedList', () { + test('returns blocked users from single page', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/users/blocks', + (server) => server.reply(200, { + 'data': [ + { + 'user_id': '99999', + 'user_login': 'blockeduser', + 'display_name': 'BlockedUser', + }, + ], + 'pagination': {'cursor': null}, + }), + queryParameters: {'first': '100', 'broadcaster_id': '12345'}, + ); + + final blocked = await api.getUserBlockedList(id: '12345'); + + expect(blocked, hasLength(1)); + expect(blocked.first.userId, '99999'); + expect(blocked.first.userLogin, 'blockeduser'); + }); + + test('returns empty list when no users blocked', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/users/blocks', + (server) => server.reply(200, { + 'data': [], + 'pagination': {'cursor': null}, + }), + queryParameters: {'first': '100', 'broadcaster_id': '12345'}, + ); + + final blocked = await api.getUserBlockedList(id: '12345'); + + expect(blocked, isEmpty); + }); + }); + + group('getTopStreams', () { + test('returns streams with pagination', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/streams', + (server) => server.reply(200, twitchStreamResponse), + ); + + final streams = await api.getTopStreams(); + + expect(streams.data, hasLength(1)); + expect(streams.data.first.userName, 'TestUser'); + expect(streams.pagination['cursor'], 'next_cursor'); + }); + + test('passes cursor for pagination', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/streams', + (server) => server.reply(200, { + 'data': [], + 'pagination': {}, + }), + queryParameters: {'after': 'page2_cursor'}, + ); + + final streams = await api.getTopStreams(cursor: 'page2_cursor'); + + expect(streams.data, isEmpty); + }); + }); + + group('getChannel', () { + test('returns channel info', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/channels', + (server) => server.reply(200, twitchChannelResponse), + queryParameters: {'broadcaster_id': '12345'}, + ); + + final channel = await api.getChannel(userId: '12345'); + + expect(channel.broadcasterId, '12345'); + expect(channel.broadcasterName, 'TestUser'); + expect(channel.title, 'Test Stream'); + expect(channel.gameName, 'Just Chatting'); + }); + + test('throws ApiException for nonexistent channel', () { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/channels', + (server) => server.reply(200, { + 'data': [], + }), + queryParameters: {'broadcaster_id': '99999'}, + ); + + expect( + () => api.getChannel(userId: '99999'), + throwsA(isA()), + ); + }); + }); + + group('getEmotesChannel', () { + test('maps emote types correctly', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/chat/emotes', + (server) => server.reply(200, { + 'data': [ + { + 'id': '1', + 'name': 'BitsEmote', + 'emote_type': 'bitstier', + 'owner_id': null, + }, + { + 'id': '2', + 'name': 'FollowerEmote', + 'emote_type': 'follower', + 'owner_id': null, + }, + { + 'id': '3', + 'name': 'SubEmote', + 'emote_type': 'subscriptions', + 'owner_id': '12345', + }, + ], + }), + queryParameters: {'broadcaster_id': '12345'}, + ); + + final emotes = await api.getEmotesChannel(id: '12345'); + + expect(emotes, hasLength(3)); + expect(emotes[0].type, EmoteType.twitchBits); + expect(emotes[1].type, EmoteType.twitchFollower); + expect(emotes[2].type, EmoteType.twitchChannel); + }); + }); +} diff --git a/test/apis/twitch_auth_interceptor_test.dart b/test/apis/twitch_auth_interceptor_test.dart new file mode 100644 index 00000000..fa4316a0 --- /dev/null +++ b/test/apis/twitch_auth_interceptor_test.dart @@ -0,0 +1,95 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/twitch_auth_interceptor.dart'; +import 'package:frosty/screens/settings/stores/auth_store.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAuthStore extends Mock implements AuthStore {} + +void main() { + late MockAuthStore mockAuthStore; + late TwitchAuthInterceptor interceptor; + + setUp(() { + mockAuthStore = MockAuthStore(); + interceptor = TwitchAuthInterceptor(mockAuthStore); + + when(() => mockAuthStore.headersTwitch).thenReturn({ + 'Authorization': 'Bearer test_token', + 'Client-Id': 'test_client_id', + }); + }); + + group('TwitchAuthInterceptor', () { + test('adds headers for Twitch Helix API requests', () { + final options = RequestOptions( + path: 'https://api.twitch.tv/helix/users', + ); + + final handler = RequestInterceptorHandler(); + + // We test by calling onRequest and checking that headers were added + interceptor.onRequest(options, handler); + + expect(options.headers['Authorization'], 'Bearer test_token'); + expect(options.headers['Client-Id'], 'test_client_id'); + }); + + test('adds headers for Twitch OAuth requests', () { + final options = RequestOptions( + path: 'https://id.twitch.tv/oauth2/validate', + ); + + final handler = RequestInterceptorHandler(); + interceptor.onRequest(options, handler); + + expect(options.headers['Authorization'], 'Bearer test_token'); + expect(options.headers['Client-Id'], 'test_client_id'); + }); + + test('does not add headers for BTTV API requests', () { + final options = RequestOptions( + path: 'https://api.betterttv.net/3/cached/emotes/global', + ); + + final handler = RequestInterceptorHandler(); + interceptor.onRequest(options, handler); + + expect(options.headers.containsKey('Authorization'), isFalse); + expect(options.headers.containsKey('Client-Id'), isFalse); + }); + + test('does not add headers for FFZ API requests', () { + final options = RequestOptions( + path: 'https://api.frankerfacez.com/v1/set/global', + ); + + final handler = RequestInterceptorHandler(); + interceptor.onRequest(options, handler); + + expect(options.headers.containsKey('Authorization'), isFalse); + }); + + test('does not add headers for 7TV API requests', () { + final options = RequestOptions( + path: 'https://7tv.io/v3/emote-sets/global', + ); + + final handler = RequestInterceptorHandler(); + interceptor.onRequest(options, handler); + + expect(options.headers.containsKey('Authorization'), isFalse); + }); + + test('does not add headers for other URLs', () { + final options = RequestOptions( + path: 'https://example.com/api/data', + ); + + final handler = RequestInterceptorHandler(); + interceptor.onRequest(options, handler); + + expect(options.headers.containsKey('Authorization'), isFalse); + }); + }); +} diff --git a/test/fixtures/api_responses.dart b/test/fixtures/api_responses.dart new file mode 100644 index 00000000..8aefa1e5 --- /dev/null +++ b/test/fixtures/api_responses.dart @@ -0,0 +1,350 @@ +/// Test fixtures containing sample API response data. +/// Reused across model serialization and API service tests. +library; + +// ── Twitch API Responses ── + +const twitchEmotesGlobalResponse = { + 'data': [ + { + 'id': '25', + 'name': 'Kappa', + 'emote_type': 'bitstier', + 'owner_id': null, + }, + { + 'id': '1902', + 'name': 'Keepo', + 'emote_type': 'globals', + 'owner_id': null, + }, + ], +}; + +const twitchBadgesGlobalResponse = { + 'data': [ + { + 'set_id': 'subscriber', + 'versions': [ + { + 'id': '0', + 'image_url_1x': 'https://cdn/sub/1x.png', + 'image_url_2x': 'https://cdn/sub/2x.png', + 'image_url_4x': 'https://cdn/sub/4x.png', + 'title': 'Subscriber', + 'description': '0 months', + }, + ], + }, + ], +}; + +const twitchUserResponse = { + 'data': [ + { + 'id': '12345', + 'login': 'testuser', + 'display_name': 'TestUser', + 'profile_image_url': 'https://cdn/profile.png', + }, + ], +}; + +const twitchStreamResponse = { + 'data': [ + { + 'user_id': '12345', + 'user_login': 'testuser', + 'user_name': 'TestUser', + 'game_id': '509658', + 'game_name': 'Just Chatting', + 'title': 'Test Stream', + 'viewer_count': 1000, + 'started_at': '2024-01-01T00:00:00Z', + 'thumbnail_url': 'https://cdn/thumb.jpg', + }, + ], + 'pagination': {'cursor': 'next_cursor'}, +}; + +const twitchCategoryResponse = { + 'data': [ + { + 'box_art_url': 'https://cdn/boxart.jpg', + 'id': '509658', + 'name': 'Just Chatting', + }, + ], + 'pagination': {'cursor': 'cat_cursor'}, +}; + +const twitchChannelResponse = { + 'data': [ + { + 'broadcaster_id': '12345', + 'broadcaster_login': 'testuser', + 'broadcaster_name': 'TestUser', + 'broadcaster_language': 'en', + 'title': 'Test Stream', + 'game_id': '509658', + 'game_name': 'Just Chatting', + }, + ], +}; + +const twitchChannelQueryResponse = { + 'data': [ + { + 'broadcaster_login': 'testuser', + 'display_name': 'TestUser', + 'id': '12345', + 'is_live': true, + 'started_at': '2024-01-01T00:00:00Z', + }, + ], +}; + +const twitchBlockedUsersResponse = { + 'data': [ + { + 'user_id': '99999', + 'user_login': 'blockeduser', + 'display_name': 'BlockedUser', + }, + ], + 'pagination': {'cursor': null}, +}; + +const twitchSharedChatSessionResponse = { + 'data': [ + { + 'session_id': 'session_123', + 'host_broadcaster_id': '12345', + 'participants': [ + {'broadcaster_id': '12345'}, + {'broadcaster_id': '67890'}, + ], + 'created_at': '2024-01-01T00:00:00Z', + 'updated_at': '2024-01-01T01:00:00Z', + }, + ], +}; + +const twitchTokenResponse = { + 'access_token': 'test_token_abc123', + 'token_type': 'bearer', + 'expires_in': 5000000, +}; + +const twitchValidateTokenResponse = { + 'client_id': 'test_client_id', + 'login': 'testuser', + 'scopes': ['chat:read', 'chat:edit'], + 'user_id': '12345', + 'expires_in': 5000000, +}; + +// ── BTTV API Responses ── + +const bttvEmotesGlobalResponse = [ + {'id': 'bttv1', 'code': 'SourPls'}, + {'id': 'bttv2', 'code': 'catJAM'}, +]; + +const bttvEmotesChannelResponse = { + 'channelEmotes': [ + {'id': 'ch1', 'code': 'ChannelEmote'}, + ], + 'sharedEmotes': [ + {'id': 'sh1', 'code': 'SharedEmote'}, + {'id': 'sh2', 'code': 'SharedEmote2'}, + ], +}; + +const bttvBadgesResponse = [ + { + 'providerId': 'user123', + 'badge': { + 'description': 'BTTV Developer', + 'svg': 'https://cdn.bttv.net/badge/dev.svg', + }, + }, + { + 'providerId': 'user456', + 'badge': { + 'description': 'BTTV Supporter', + 'svg': 'https://cdn.bttv.net/badge/supporter.svg', + }, + }, +]; + +// ── FFZ API Responses ── + +const ffzEmotesGlobalResponse = { + 'default_sets': [1], + 'sets': { + '1': { + 'emoticons': [ + { + 'name': 'LULW', + 'height': 28, + 'width': 28, + 'owner': {'display_name': 'FFZOwner', 'name': 'ffzowner'}, + 'urls': { + '1': 'https://cdn.ffz.net/1x.png', + '2': 'https://cdn.ffz.net/2x.png', + '4': 'https://cdn.ffz.net/4x.png', + }, + 'animated': null, + }, + ], + }, + }, +}; + +const ffzRoomInfoResponse = { + 'room': { + 'set': 123, + 'vip_badge': null, + 'mod_urls': null, + }, + 'sets': { + '123': { + 'emoticons': [ + { + 'name': 'ChannelFFZ', + 'height': 32, + 'width': 32, + 'owner': {'display_name': 'Streamer', 'name': 'streamer'}, + 'urls': { + '1': 'https://cdn.ffz.net/ch/1x.png', + '2': 'https://cdn.ffz.net/ch/2x.png', + '4': 'https://cdn.ffz.net/ch/4x.png', + }, + 'animated': null, + }, + ], + }, + }, +}; + +const ffzBadgesResponse = { + 'badges': [ + { + 'id': 1, + 'title': 'FFZ Developer', + 'color': '#FF0000', + 'urls': { + '1': 'https://cdn.ffz.net/badge/1x.png', + '2': 'https://cdn.ffz.net/badge/2x.png', + '4': 'https://cdn.ffz.net/badge/4x.png', + }, + }, + { + 'id': 2, + 'title': 'FFZ Supporter', + 'color': '#00FF00', + 'urls': { + '1': 'https://cdn.ffz.net/badge2/1x.png', + '2': 'https://cdn.ffz.net/badge2/2x.png', + '4': 'https://cdn.ffz.net/badge2/4x.png', + }, + }, + ], + 'users': { + '1': [11111, 22222], + '2': [22222, 33333], + }, +}; + +// ── 7TV API Responses ── + +const sevenTVEmotesGlobalResponse = { + 'emotes': [ + { + 'id': '7tv1', + 'name': 'EZ', + 'data': { + 'id': '7tv1', + 'name': 'EZ', + 'flags': 0, + 'owner': {'username': 'creator', 'display_name': 'Creator'}, + 'host': { + 'url': '//cdn.7tv.app/emote/7tv1', + 'files': [ + {'name': '1x.webp', 'width': 32, 'height': 32, 'format': 'WEBP'}, + {'name': '2x.webp', 'width': 64, 'height': 64, 'format': 'WEBP'}, + {'name': '3x.webp', 'width': 96, 'height': 96, 'format': 'WEBP'}, + { + 'name': '4x.webp', + 'width': 128, + 'height': 128, + 'format': 'WEBP', + }, + ], + }, + }, + }, + ], +}; + +const sevenTVEmotesChannelResponse = { + 'emote_set': { + 'id': 'set_abc123', + 'emotes': [ + { + 'id': '7tv_ch1', + 'name': 'Clap', + 'data': { + 'id': '7tv_ch1', + 'name': 'Clap', + 'flags': 0, + 'owner': null, + 'host': { + 'url': '//cdn.7tv.app/emote/7tv_ch1', + 'files': [ + { + 'name': '1x.webp', + 'width': 32, + 'height': 32, + 'format': 'WEBP', + }, + { + 'name': '4x.webp', + 'width': 128, + 'height': 128, + 'format': 'WEBP', + }, + ], + }, + }, + }, + ], + }, +}; + +// ── Chatters Response ── + +const chattersResponse = { + 'chatter_count': 5, + 'chatters': { + 'broadcaster': ['streamer'], + 'vips': ['vip1'], + 'moderators': ['mod1'], + 'staff': [], + 'admins': [], + 'global_mods': [], + 'viewers': ['viewer1', 'viewer2'], + }, +}; + +// ── 7TV Events ── + +const sevenTVEventJson = { + 'op': 0, + 't': 1, + 'd': { + 'type': 'emote_set.update', + 'condition': {'object_id': 'set_abc123'}, + }, +}; diff --git a/test/fixtures/irc_messages.dart b/test/fixtures/irc_messages.dart index 8d16f5dd..28624337 100644 --- a/test/fixtures/irc_messages.dart +++ b/test/fixtures/irc_messages.dart @@ -152,6 +152,20 @@ const messageWithEmoji = 'tmi-sent-ts=1234567890123;turbo=0;user-id=14141;user-type= ' ':emojiuser!emojiuser@emojiuser.tmi.twitch.tv PRIVMSG #channel :Hello 😀🎉 World'; +/// A message with empty body (edge case) +const messageEmptyBody = + '@badge-info=;badges=;color=#FF0000;display-name=EmptyMsg;emotes=;' + 'id=empty-123;mod=0;room-id=12345;subscriber=0;' + 'tmi-sent-ts=1234567890123;turbo=0;user-id=16161;user-type= ' + ':emptymsg!emptymsg@emptymsg.tmi.twitch.tv PRIVMSG #channel :'; + +/// A message containing only emotes (no text words) +const messageOnlyEmotes = + '@badge-info=;badges=;color=#FF0000;display-name=EmoteOnly;' + 'emotes=25:0-4,6-10;id=emoteonly-123;mod=0;room-id=12345;subscriber=0;' + 'tmi-sent-ts=1234567890123;turbo=0;user-id=17171;user-type= ' + ':emoteonly!emoteonly@emoteonly.tmi.twitch.tv PRIVMSG #channel :Kappa Kappa'; + /// A message with a URL const messageWithUrl = '@badge-info=;badges=;color=#9400D3;display-name=LinkUser;emotes=;' diff --git a/test/models/category_test.dart b/test/models/category_test.dart new file mode 100644 index 00000000..6623b7cf --- /dev/null +++ b/test/models/category_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/models/category.dart'; + +void main() { + group('CategoryTwitch', () { + test('fromJson deserializes snake_case fields', () { + final category = CategoryTwitch.fromJson(const { + 'box_art_url': 'https://cdn/boxart.jpg', + 'id': '509658', + 'name': 'Just Chatting', + }); + + expect(category.boxArtUrl, 'https://cdn/boxart.jpg'); + expect(category.id, '509658'); + expect(category.name, 'Just Chatting'); + }); + }); + + group('CategoriesTwitch', () { + test('fromJson deserializes data list and pagination', () { + final categories = CategoriesTwitch.fromJson(const { + 'data': [ + { + 'box_art_url': 'https://cdn/boxart.jpg', + 'id': '509658', + 'name': 'Just Chatting', + }, + ], + 'pagination': {'cursor': 'cat_cursor'}, + }); + + expect(categories.data.length, 1); + expect(categories.data.first.name, 'Just Chatting'); + expect(categories.pagination?['cursor'], 'cat_cursor'); + }); + + test('fromJson handles empty data list', () { + final categories = CategoriesTwitch.fromJson(const { + 'data': [], + 'pagination': {}, + }); + + expect(categories.data, isEmpty); + }); + + test('fromJson handles null pagination', () { + final categories = CategoriesTwitch.fromJson(const { + 'data': [], + 'pagination': null, + }); + + expect(categories.pagination, isNull); + }); + }); +} diff --git a/test/models/channel_test.dart b/test/models/channel_test.dart new file mode 100644 index 00000000..6a08ec86 --- /dev/null +++ b/test/models/channel_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/models/channel.dart'; + +import '../fixtures/api_responses.dart'; + +void main() { + group('Channel', () { + test('fromJson deserializes snake_case fields', () { + final channel = Channel.fromJson( + (twitchChannelResponse['data']! as List).first as Map, + ); + + expect(channel.broadcasterId, '12345'); + expect(channel.broadcasterLogin, 'testuser'); + expect(channel.broadcasterName, 'TestUser'); + expect(channel.broadcasterLanguage, 'en'); + expect(channel.title, 'Test Stream'); + expect(channel.gameId, '509658'); + expect(channel.gameName, 'Just Chatting'); + }); + }); + + group('ChannelQuery', () { + test('fromJson deserializes snake_case fields', () { + final query = ChannelQuery.fromJson( + (twitchChannelQueryResponse['data']! as List).first + as Map, + ); + + expect(query.broadcasterLogin, 'testuser'); + expect(query.displayName, 'TestUser'); + expect(query.id, '12345'); + expect(query.isLive, isTrue); + expect(query.startedAt, '2024-01-01T00:00:00Z'); + }); + + test('fromJson handles isLive false', () { + final query = ChannelQuery.fromJson(const { + 'broadcaster_login': 'offline', + 'display_name': 'Offline', + 'id': '99999', + 'is_live': false, + 'started_at': '', + }); + + expect(query.isLive, isFalse); + }); + }); +} diff --git a/test/models/chatters_test.dart b/test/models/chatters_test.dart new file mode 100644 index 00000000..95eca790 --- /dev/null +++ b/test/models/chatters_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/models/chatters.dart'; + +import '../fixtures/api_responses.dart'; + +void main() { + group('ChatUsers', () { + test('fromJson deserializes chatter count and chatters', () { + final chatUsers = ChatUsers.fromJson( + chattersResponse as Map, + ); + + expect(chatUsers.chatterCount, 5); + expect(chatUsers.chatters.broadcaster, ['streamer']); + expect(chatUsers.chatters.vips, ['vip1']); + expect(chatUsers.chatters.moderators, ['mod1']); + expect(chatUsers.chatters.viewers, ['viewer1', 'viewer2']); + }); + }); + + group('Chatters', () { + test('fromJson handles all role lists', () { + final chatters = Chatters.fromJson( + chattersResponse['chatters'] as Map, + ); + + expect(chatters.broadcaster, hasLength(1)); + expect(chatters.vips, hasLength(1)); + expect(chatters.moderators, hasLength(1)); + expect(chatters.staff, isEmpty); + expect(chatters.admins, isEmpty); + expect(chatters.globalMods, isEmpty); + expect(chatters.viewers, hasLength(2)); + }); + }); +} diff --git a/test/models/emotes_test.dart b/test/models/emotes_test.dart index 4b049a77..08ec7ca5 100644 --- a/test/models/emotes_test.dart +++ b/test/models/emotes_test.dart @@ -34,6 +34,19 @@ void main() { expect(emote.ownerId, '67890'); }); + + test('handles null owner ID', () { + final twitchEmote = EmoteTwitch( + '25', + 'Kappa', + 'bitstier', + null, + ); + + final emote = Emote.fromTwitch(twitchEmote, EmoteType.twitchGlobal); + + expect(emote.ownerId, isNull); + }); }); group('Emote.fromBTTV', () { @@ -47,53 +60,21 @@ void main() { expect(emote.type, EmoteType.bttvGlobal); }); - test('marks SoSnowy as zero-width', () { - final bttvEmote = EmoteBTTV('snowyid', 'SoSnowy'); - - final emote = Emote.fromBTTV(bttvEmote, EmoteType.bttvGlobal); - - expect(emote.zeroWidth, isTrue); - }); - - test('marks IceCold as zero-width', () { - final bttvEmote = EmoteBTTV('iceid', 'IceCold'); - - final emote = Emote.fromBTTV(bttvEmote, EmoteType.bttvChannel); - - expect(emote.zeroWidth, isTrue); - }); - - test('marks SantaHat as zero-width', () { - final bttvEmote = EmoteBTTV('santaid', 'SantaHat'); - - final emote = Emote.fromBTTV(bttvEmote, EmoteType.bttvShared); - - expect(emote.zeroWidth, isTrue); - }); - - test('marks TopHat as zero-width', () { - final bttvEmote = EmoteBTTV('topid', 'TopHat'); - - final emote = Emote.fromBTTV(bttvEmote, EmoteType.bttvGlobal); - - expect(emote.zeroWidth, isTrue); - }); - - test('marks cvMask as zero-width', () { - final bttvEmote = EmoteBTTV('maskid', 'cvMask'); - - final emote = Emote.fromBTTV(bttvEmote, EmoteType.bttvGlobal); - - expect(emote.zeroWidth, isTrue); - }); - - test('marks cvHazmat as zero-width', () { - final bttvEmote = EmoteBTTV('hazmatid', 'cvHazmat'); - - final emote = Emote.fromBTTV(bttvEmote, EmoteType.bttvGlobal); - - expect(emote.zeroWidth, isTrue); - }); + for (final name in [ + 'SoSnowy', + 'IceCold', + 'SantaHat', + 'TopHat', + 'ReinDeer', + 'CandyCane', + 'cvMask', + 'cvHazmat', + ]) { + test('marks $name as zero-width', () { + final emote = Emote.fromBTTV(EmoteBTTV('id', name), EmoteType.bttvGlobal); + expect(emote.zeroWidth, isTrue); + }); + } test('regular emotes are not zero-width', () { final bttvEmote = EmoteBTTV('regularid', 'RegularEmote'); @@ -214,6 +195,29 @@ void main() { // Should use animated 2x since animated 4x is not available expect(emote.url, 'https://cdn.ffz.net/anim/2x.gif'); }); + + test('animated with only 1x falls back to animated 1x', () { + final ffzEmote = EmoteFFZ( + 'AnimOnly1x', + 24, + 24, + const OwnerFFZ(displayName: 'Owner', name: 'owner'), + const ImagesFFZ( + 'https://cdn.ffz.net/static/1x.png', + 'https://cdn.ffz.net/static/2x.png', + 'https://cdn.ffz.net/static/4x.png', + ), + const ImagesFFZ( + 'https://cdn.ffz.net/anim/1x.gif', + null, // No animated 2x + null, // No animated 4x + ), + ); + + final emote = Emote.fromFFZ(ffzEmote, EmoteType.ffzGlobal); + + expect(emote.url, 'https://cdn.ffz.net/anim/1x.gif'); + }); }); group('Emote.from7TV', () { @@ -473,6 +477,27 @@ void main() { }); }); + group('EmoteBTTVChannel', () { + test('combines channel and shared emotes', () { + final json = { + 'channelEmotes': [ + {'id': 'ch1', 'code': 'ChannelEmote'}, + ], + 'sharedEmotes': [ + {'id': 'sh1', 'code': 'SharedEmote1'}, + {'id': 'sh2', 'code': 'SharedEmote2'}, + ], + }; + + final result = EmoteBTTVChannel.fromJson(json); + + expect(result.channelEmotes.length, 1); + expect(result.channelEmotes.first.code, 'ChannelEmote'); + expect(result.sharedEmotes.length, 2); + expect(result.sharedEmotes.first.code, 'SharedEmote1'); + }); + }); + group('Emote JSON serialization', () { test('roundtrip serialization works', () { const original = Emote( diff --git a/test/models/events_test.dart b/test/models/events_test.dart new file mode 100644 index 00000000..168dd39c --- /dev/null +++ b/test/models/events_test.dart @@ -0,0 +1,110 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/models/events.dart'; + +import '../fixtures/api_responses.dart'; + +void main() { + group('SevenTVEvent', () { + test('fromJson deserializes op, t, and d fields', () { + final event = SevenTVEvent.fromJson( + sevenTVEventJson as Map, + ); + + expect(event.op, 0); + expect(event.t, 1); + expect(event.d.type, 'emote_set.update'); + }); + + test('toJson roundtrip preserves data', () { + final event = SevenTVEvent.fromJson( + sevenTVEventJson as Map, + ); + + // Use jsonEncode/jsonDecode for a true roundtrip, since toJson() + // doesn't recursively serialize nested objects without explicitToJson. + final json = jsonDecode(jsonEncode(event.toJson())) + as Map; + final restored = SevenTVEvent.fromJson(json); + + expect(restored.op, event.op); + expect(restored.t, event.t); + expect(restored.d.type, event.d.type); + }); + + test('fromJson handles null t field', () { + final event = SevenTVEvent.fromJson(const { + 'op': 2, + 'd': {'type': null}, + }); + + expect(event.op, 2); + expect(event.t, isNull); + }); + }); + + group('SevenTVEventData', () { + test('fromJson deserializes type and condition', () { + final data = SevenTVEventData.fromJson( + (sevenTVEventJson as Map)['d'] + as Map, + ); + + expect(data.type, 'emote_set.update'); + expect(data.condition?['object_id'], 'set_abc123'); + }); + + test('fromJson with null type', () { + final data = SevenTVEventData.fromJson(const { + 'type': null, + 'condition': null, + }); + + expect(data.type, isNull); + expect(data.condition, isNull); + }); + + test('toJson produces valid output', () { + const data = SevenTVEventData( + type: 'emote_set.update', + condition: {'object_id': 'test'}, + ); + + final json = data.toJson(); + + expect(json['type'], 'emote_set.update'); + expect(json['condition'], {'object_id': 'test'}); + }); + }); + + group('SevenTVEventEmoteSetBody', () { + test('fromJson deserializes actor and pushed/pulled lists', () { + final body = SevenTVEventEmoteSetBody.fromJson(const { + 'actor': { + 'username': 'moderator', + 'display_name': 'Moderator', + }, + 'pushed': [], + 'pulled': [], + }); + + expect(body.actor.username, 'moderator'); + expect(body.actor.displayName, 'Moderator'); + expect(body.pushed, isEmpty); + expect(body.pulled, isEmpty); + }); + + test('fromJson handles null pushed/pulled', () { + final body = SevenTVEventEmoteSetBody.fromJson(const { + 'actor': { + 'username': 'mod', + 'display_name': 'Mod', + }, + }); + + expect(body.pushed, isNull); + expect(body.pulled, isNull); + }); + }); +} diff --git a/test/models/irc_test.dart b/test/models/irc_test.dart index 5f76cd3f..0d57776d 100644 --- a/test/models/irc_test.dart +++ b/test/models/irc_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/constants.dart'; import 'package:frosty/models/irc.dart'; import '../fixtures/irc_messages.dart'; @@ -173,7 +174,7 @@ void main() { expect(msg.mention, isFalse); }); - test('mention is null when userLogin not provided', () { + test('mention is false when userLogin not provided', () { final msg = IRCMessage.fromString(basicPrivmsg); expect(msg.mention, isFalse); }); @@ -228,6 +229,17 @@ void main() { expect(msg.tags['source-room-id'], '99999'); expect(msg.tags['room-id'], '12345'); }); + + test('parses message with URL in split', () { + final msg = IRCMessage.fromString(messageWithUrl); + + expect(msg.message, contains('https://twitch.tv/channel')); + expect(msg.split, isNotNull); + expect( + msg.split!.any((word) => regexLink.hasMatch(word)), + isTrue, + ); + }); }); }); @@ -456,6 +468,126 @@ void main() { }); }); + group('IRCMessage.clearChat — buffered messages', () { + test('marks buffered messages from banned user', () { + final messages = []; + final bufferedMessages = [ + IRCMessage( + raw: '', + command: Command.privateMessage, + tags: {'id': 'buf-1'}, + user: 'banneduser', + message: 'Buffered msg', + ), + IRCMessage( + raw: '', + command: Command.privateMessage, + tags: {'id': 'buf-2'}, + user: 'innocentuser', + message: 'Other msg', + ), + ]; + + final clearChatMsg = IRCMessage( + raw: '', + command: Command.clearChat, + tags: {'ban-duration': '600'}, + message: 'banneduser', + ); + + IRCMessage.clearChat( + messages: messages, + bufferedMessages: bufferedMessages, + ircMessage: clearChatMsg, + ); + + expect(bufferedMessages[0].command, Command.clearChat); + expect(bufferedMessages[0].tags['ban-duration'], '600'); + expect(bufferedMessages[1].command, Command.privateMessage); + }); + + test('full chat clear also clears buffered messages', () { + final messages = [ + IRCMessage( + raw: '', + command: Command.privateMessage, + tags: {}, + message: 'msg', + ), + ]; + final bufferedMessages = [ + IRCMessage( + raw: '', + command: Command.privateMessage, + tags: {}, + message: 'buffered', + ), + ]; + + final clearAllMsg = IRCMessage( + raw: '', + command: Command.clearChat, + tags: {}, + ); + + IRCMessage.clearChat( + messages: messages, + bufferedMessages: bufferedMessages, + ircMessage: clearAllMsg, + ); + + expect(bufferedMessages, isEmpty); + }); + }); + + group('IRCMessage.clearMessage — buffered messages', () { + test('marks buffered message by ID', () { + final messages = []; + final bufferedMessages = [ + IRCMessage( + raw: '', + command: Command.privateMessage, + tags: {'id': 'buf-target'}, + user: 'someuser', + message: 'To be deleted', + ), + ]; + + final clearMsg = IRCMessage( + raw: '', + command: Command.clearMessage, + tags: {'target-msg-id': 'buf-target'}, + ); + + IRCMessage.clearMessage( + messages: messages, + bufferedMessages: bufferedMessages, + ircMessage: clearMsg, + ); + + expect(bufferedMessages.first.command, Command.clearMessage); + }); + }); + + group('IRCMessage edge cases', () { + test('parses message with empty body', () { + final msg = IRCMessage.fromString(messageEmptyBody); + + expect(msg.command, Command.privateMessage); + // Empty trailing content after the colon + expect(msg.message, isEmpty); + }); + + test('parses emote-only message', () { + final msg = IRCMessage.fromString(messageOnlyEmotes); + + expect(msg.command, Command.privateMessage); + expect(msg.message, 'Kappa Kappa'); + expect(msg.localEmotes, isNotNull); + expect(msg.localEmotes!.containsKey('Kappa'), isTrue); + }); + }); + group('USERSTATE', () { test('creates default USERSTATE', () { const userstate = USERSTATE(); diff --git a/test/models/shared_chat_session_test.dart b/test/models/shared_chat_session_test.dart new file mode 100644 index 00000000..d3d5c0a0 --- /dev/null +++ b/test/models/shared_chat_session_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/models/shared_chat_session.dart'; + +import '../fixtures/api_responses.dart'; + +void main() { + group('SharedChatSession', () { + test('fromJson deserializes all fields', () { + final session = SharedChatSession.fromJson( + (twitchSharedChatSessionResponse['data']! as List).first + as Map, + ); + + expect(session.sessionId, 'session_123'); + expect(session.hostBroadcasterId, '12345'); + expect(session.participants.length, 2); + expect(session.createdAt, '2024-01-01T00:00:00Z'); + expect(session.updatedAt, '2024-01-01T01:00:00Z'); + }); + }); + + group('Participant', () { + test('fromJson deserializes broadcaster_id', () { + final participant = Participant.fromJson(const { + 'broadcaster_id': '67890', + }); + + expect(participant.broadcasterId, '67890'); + }); + + test('participants from shared chat session are correct', () { + final session = SharedChatSession.fromJson( + (twitchSharedChatSessionResponse['data']! as List).first + as Map, + ); + + expect(session.participants[0].broadcasterId, '12345'); + expect(session.participants[1].broadcasterId, '67890'); + }); + }); +} diff --git a/test/models/stream_test.dart b/test/models/stream_test.dart new file mode 100644 index 00000000..76ef1105 --- /dev/null +++ b/test/models/stream_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/models/stream.dart'; + +import '../fixtures/api_responses.dart'; + +void main() { + group('StreamTwitch', () { + test('fromJson deserializes snake_case fields', () { + final stream = StreamTwitch.fromJson( + (twitchStreamResponse['data']! as List).first as Map, + ); + + expect(stream.userId, '12345'); + expect(stream.userLogin, 'testuser'); + expect(stream.userName, 'TestUser'); + expect(stream.gameId, '509658'); + expect(stream.gameName, 'Just Chatting'); + expect(stream.title, 'Test Stream'); + expect(stream.viewerCount, 1000); + expect(stream.startedAt, '2024-01-01T00:00:00Z'); + expect(stream.thumbnailUrl, 'https://cdn/thumb.jpg'); + }); + }); + + group('StreamsTwitch', () { + test('fromJson deserializes data and pagination', () { + final streams = StreamsTwitch.fromJson( + twitchStreamResponse as Map, + ); + + expect(streams.data.length, 1); + expect(streams.data.first.userName, 'TestUser'); + expect(streams.pagination['cursor'], 'next_cursor'); + }); + + test('fromJson handles empty data list', () { + final streams = StreamsTwitch.fromJson(const { + 'data': [], + 'pagination': {}, + }); + + expect(streams.data, isEmpty); + }); + }); +} diff --git a/test/models/user_test.dart b/test/models/user_test.dart new file mode 100644 index 00000000..d8de1787 --- /dev/null +++ b/test/models/user_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/models/user.dart'; + +import '../fixtures/api_responses.dart'; + +void main() { + group('UserTwitch', () { + test('fromJson deserializes snake_case fields', () { + final user = UserTwitch.fromJson( + (twitchUserResponse['data']! as List).first as Map, + ); + + expect(user.id, '12345'); + expect(user.login, 'testuser'); + expect(user.displayName, 'TestUser'); + expect(user.profileImageUrl, 'https://cdn/profile.png'); + }); + }); + + group('UserBlockedTwitch', () { + test('fromJson deserializes snake_case fields', () { + final blocked = UserBlockedTwitch.fromJson( + (twitchBlockedUsersResponse['data']! as List).first + as Map, + ); + + expect(blocked.userId, '99999'); + expect(blocked.userLogin, 'blockeduser'); + expect(blocked.displayName, 'BlockedUser'); + }); + + test('fromJson with different values', () { + final blocked = UserBlockedTwitch.fromJson(const { + 'user_id': '11111', + 'user_login': 'another_blocked', + 'display_name': 'AnotherBlocked', + }); + + expect(blocked.userId, '11111'); + expect(blocked.userLogin, 'another_blocked'); + expect(blocked.displayName, 'AnotherBlocked'); + }); + }); +} diff --git a/test/regex_test.dart b/test/regex_test.dart index b47decb7..d97486ce 100644 --- a/test/regex_test.dart +++ b/test/regex_test.dart @@ -106,8 +106,10 @@ void main() { expect(regexLink.hasMatch('localhost'), isFalse); }); - test('does not match email addresses as URLs', () { - expect(regexLink.hasMatch('example.com'), isTrue); + test('matches domain part of email addresses', () { + // The regex matches the domain portion (example.com) within an email + // This is expected behavior — the regex is for URLs, not email filtering + expect(regexLink.hasMatch('user@example.com'), isTrue); }); }); @@ -224,8 +226,11 @@ void main() { test('handles consecutive emojis', () { const text = '😀😃😄'; final matches = regexEmoji.allMatches(text); - // May match as one or multiple depending on regex - expect(matches.isNotEmpty, isTrue); + // Consecutive emojis may be grouped into a single match by the regex + expect(matches.length, greaterThanOrEqualTo(1)); + // Verify all emoji characters are captured + final matched = matches.map((m) => m.group(0)).join(); + expect(matched, text); }); }); }); diff --git a/test/screens/settings/stores/settings_store_test.dart b/test/screens/settings/stores/settings_store_test.dart new file mode 100644 index 00000000..8dc7dcc8 --- /dev/null +++ b/test/screens/settings/stores/settings_store_test.dart @@ -0,0 +1,278 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/screens/settings/stores/settings_store.dart'; + +void main() { + group('SettingsStore JSON serialization', () { + test('fromJson with empty map returns all defaults', () { + final store = SettingsStore.fromJson({}); + + expect(store.themeType, ThemeType.system); + expect(store.accentColor, 0xff9146ff); + expect(store.showThumbnails, isTrue); + expect(store.largeStreamCard, isFalse); + expect(store.launchUrlExternal, isFalse); + expect(store.showVideo, isTrue); + expect(store.defaultToHighestQuality, isFalse); + expect(store.useTextureRendering, isTrue); + expect(store.showOverlay, isTrue); + expect(store.toggleableOverlay, isFalse); + expect(store.showLatency, isFalse); + expect(store.badgeScale, 1.0); + expect(store.emoteScale, 1.0); + expect(store.messageScale, 1.0); + expect(store.messageSpacing, 8.0); + expect(store.fontSize, 12.0); + expect(store.showDeletedMessages, isFalse); + expect(store.showChatMessageDividers, isFalse); + expect(store.timestampType, TimestampType.disabled); + expect(store.autoSyncChatDelay, isFalse); + expect(store.chatDelay, 0.0); + expect(store.highlightFirstTimeChatter, isTrue); + expect(store.showUserNotices, isTrue); + expect(store.emoteMenuButtonOnLeft, isFalse); + expect(store.landscapeChatLeftSide, isFalse); + expect(store.landscapeForceVerticalChat, isFalse); + expect(store.landscapeCutout, LandscapeCutoutType.none); + expect(store.chatWidth, 0.2); + expect(store.fullScreenChatOverlayOpacity, 0.5); + expect(store.autocomplete, isTrue); + expect(store.showTwitchEmotes, isTrue); + expect(store.showTwitchBadges, isTrue); + expect(store.show7TVEmotes, isTrue); + expect(store.showBTTVEmotes, isTrue); + expect(store.showBTTVBadges, isTrue); + expect(store.showFFZEmotes, isTrue); + expect(store.showFFZBadges, isTrue); + expect(store.showRecentMessages, isFalse); + expect(store.persistChatTabs, isTrue); + expect(store.secondaryTabs, isEmpty); + expect(store.mutedWords, isEmpty); + expect(store.matchWholeWord, isTrue); + expect(store.shareCrashLogsAndAnalytics, isTrue); + expect(store.fullScreen, isFalse); + expect(store.fullScreenChatOverlay, isFalse); + expect(store.pinnedChannelIds, isEmpty); + }); + + test('roundtrip serialization preserves non-default values', () { + final store = SettingsStore.fromJson({}); + + // Set non-default values + store.themeType = ThemeType.dark; + store.accentColor = 0xFF00FF00; + store.showThumbnails = false; + store.fontSize = 16.0; + store.chatDelay = 5.0; + store.showVideo = false; + store.timestampType = TimestampType.twelve; + store.landscapeCutout = LandscapeCutoutType.both; + store.mutedWords = ['spam', 'bad']; + store.pinnedChannelIds = ['ch1', 'ch2']; + + final json = store.toJson(); + final restored = SettingsStore.fromJson(json); + + expect(restored.themeType, ThemeType.dark); + expect(restored.accentColor, 0xFF00FF00); + expect(restored.showThumbnails, isFalse); + expect(restored.fontSize, 16.0); + expect(restored.chatDelay, 5.0); + expect(restored.showVideo, isFalse); + expect(restored.timestampType, TimestampType.twelve); + expect(restored.landscapeCutout, LandscapeCutoutType.both); + expect(restored.mutedWords, ['spam', 'bad']); + expect(restored.pinnedChannelIds, ['ch1', 'ch2']); + }); + + test('unknown ThemeType enum value falls back to system', () { + final store = SettingsStore.fromJson({ + 'themeType': 'nonexistent_theme', + }); + expect(store.themeType, ThemeType.system); + }); + + test('unknown TimestampType enum value falls back to disabled', () { + final store = SettingsStore.fromJson({ + 'timestampType': 'nonexistent_timestamp', + }); + expect(store.timestampType, TimestampType.disabled); + }); + }); + + group('SettingsStore reset actions', () { + test('resetGeneralSettings restores general defaults', () { + final store = SettingsStore.fromJson({}); + + store.themeType = ThemeType.dark; + store.accentColor = 0xFF00FF00; + store.showThumbnails = false; + store.largeStreamCard = true; + store.launchUrlExternal = true; + + store.resetGeneralSettings(); + + expect(store.themeType, ThemeType.system); + expect(store.accentColor, 0xff9146ff); + expect(store.showThumbnails, isTrue); + expect(store.largeStreamCard, isFalse); + expect(store.launchUrlExternal, isFalse); + }); + + test('resetVideoSettings restores video defaults', () { + final store = SettingsStore.fromJson({}); + + store.showVideo = false; + store.defaultToHighestQuality = true; + store.useTextureRendering = false; + store.showOverlay = false; + store.toggleableOverlay = true; + store.showLatency = true; + + store.resetVideoSettings(); + + expect(store.showVideo, isTrue); + expect(store.defaultToHighestQuality, isFalse); + expect(store.useTextureRendering, isTrue); + expect(store.showOverlay, isTrue); + expect(store.toggleableOverlay, isFalse); + expect(store.showLatency, isFalse); + }); + + test('resetChatSettings restores chat defaults', () { + final store = SettingsStore.fromJson({}); + + store.badgeScale = 2.0; + store.emoteScale = 2.0; + store.fontSize = 20.0; + store.showDeletedMessages = true; + store.timestampType = TimestampType.twentyFour; + store.chatDelay = 10.0; + store.emoteMenuButtonOnLeft = true; + store.landscapeCutout = LandscapeCutoutType.right; + store.chatWidth = 0.5; + store.mutedWords = ['word1']; + store.showBTTVEmotes = false; + store.showFFZBadges = false; + + store.resetChatSettings(); + + expect(store.badgeScale, 1.0); + expect(store.emoteScale, 1.0); + expect(store.fontSize, 12.0); + expect(store.showDeletedMessages, isFalse); + expect(store.timestampType, TimestampType.disabled); + expect(store.chatDelay, 0.0); + expect(store.emoteMenuButtonOnLeft, isFalse); + expect(store.landscapeCutout, LandscapeCutoutType.none); + expect(store.chatWidth, 0.2); + expect(store.mutedWords, isEmpty); + expect(store.showBTTVEmotes, isTrue); + expect(store.showFFZBadges, isTrue); + }); + + test('resetOtherSettings restores other defaults', () { + final store = SettingsStore.fromJson({}); + + store.shareCrashLogsAndAnalytics = false; + + store.resetOtherSettings(); + + expect(store.shareCrashLogsAndAnalytics, isTrue); + }); + + test('resetGlobalConfigs restores global config defaults', () { + final store = SettingsStore.fromJson({}); + + store.fullScreen = true; + store.fullScreenChatOverlay = true; + store.pinnedChannelIds = ['ch1']; + + store.resetGlobalConfigs(); + + expect(store.fullScreen, isFalse); + expect(store.fullScreenChatOverlay, isFalse); + expect(store.pinnedChannelIds, isEmpty); + }); + + test('resetAllSettings restores everything to defaults', () { + final store = SettingsStore.fromJson({}); + + // Modify settings across all categories + store.themeType = ThemeType.light; + store.showVideo = false; + store.fontSize = 20.0; + store.shareCrashLogsAndAnalytics = false; + store.fullScreen = true; + + store.resetAllSettings(); + + expect(store.themeType, ThemeType.system); + expect(store.showVideo, isTrue); + expect(store.fontSize, 12.0); + expect(store.shareCrashLogsAndAnalytics, isTrue); + expect(store.fullScreen, isFalse); + }); + + test('resetGeneralSettings does not affect video settings', () { + final store = SettingsStore.fromJson({}); + + store.showVideo = false; + store.themeType = ThemeType.dark; + + store.resetGeneralSettings(); + + expect(store.themeType, ThemeType.system); + expect(store.showVideo, isFalse); // Should remain changed + }); + + test('resetVideoSettings does not affect chat settings', () { + final store = SettingsStore.fromJson({}); + + store.fontSize = 20.0; + store.showLatency = true; + + store.resetVideoSettings(); + + expect(store.showLatency, isFalse); + expect(store.fontSize, 20.0); // Should remain changed + }); + }); + + group('Enums', () { + test('ThemeType has expected values', () { + expect(ThemeType.values.length, 3); + expect(ThemeType.values, contains(ThemeType.system)); + expect(ThemeType.values, contains(ThemeType.light)); + expect(ThemeType.values, contains(ThemeType.dark)); + }); + + test('TimestampType has expected values', () { + expect(TimestampType.values.length, 3); + expect(TimestampType.values, contains(TimestampType.disabled)); + expect(TimestampType.values, contains(TimestampType.twelve)); + expect(TimestampType.values, contains(TimestampType.twentyFour)); + }); + + test('LandscapeCutoutType has expected values', () { + expect(LandscapeCutoutType.values.length, 4); + expect(LandscapeCutoutType.values, contains(LandscapeCutoutType.none)); + expect(LandscapeCutoutType.values, contains(LandscapeCutoutType.left)); + expect(LandscapeCutoutType.values, contains(LandscapeCutoutType.right)); + expect(LandscapeCutoutType.values, contains(LandscapeCutoutType.both)); + }); + + test('themeNames matches ThemeType order', () { + expect(themeNames.length, ThemeType.values.length); + expect(themeNames[0], 'System'); + expect(themeNames[1], 'Light'); + expect(themeNames[2], 'Dark'); + }); + + test('timestampNames matches TimestampType order', () { + expect(timestampNames.length, TimestampType.values.length); + expect(timestampNames[0], 'Disabled'); + expect(timestampNames[1], '12-hour'); + expect(timestampNames[2], '24-hour'); + }); + }); +} diff --git a/test/utils_test.dart b/test/utils_test.dart index 9409fa5a..846540f6 100644 --- a/test/utils_test.dart +++ b/test/utils_test.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:frosty/utils.dart'; @@ -290,5 +292,48 @@ void main() { ); } }); + + testWidgets('adjusted color meets WCAG AA 4.5:1 contrast ratio', + (tester) async { + late Color adjustedColor; + const darkBackground = Color(0xFF121212); + const darkBlue = Color(0xFF0000AA); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.dark().copyWith( + scaffoldBackgroundColor: darkBackground, + ), + home: Scaffold( + body: Builder( + builder: (context) { + adjustedColor = adjustChatNameColor(context, darkBlue); + return const SizedBox(); + }, + ), + ), + ), + ); + + // Compute WCAG contrast ratio manually + double linearize(double c) => c <= 0.03928 + ? c / 12.92 + : math.pow((c + 0.055) / 1.055, 2.4).toDouble(); + + double luminance(Color c) { + return 0.2126 * linearize(c.r) + + 0.7152 * linearize(c.g) + + 0.0722 * linearize(c.b); + } + + final l1 = luminance(adjustedColor); + final l2 = luminance(darkBackground); + final hi = l1 > l2 ? l1 : l2; + final lo = l1 > l2 ? l2 : l1; + final ratio = (hi + 0.05) / (lo + 0.05); + + expect(ratio, greaterThanOrEqualTo(4.5), + reason: 'Adjusted color should meet WCAG AA 4.5:1 contrast ratio'); + }); }); } From ed80799cae62939568af37d20f5d278341cef6f5 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sat, 7 Feb 2026 01:02:47 -0800 Subject: [PATCH 004/126] simplify agents md --- AGENTS.md | 159 +++++++----------------------------------------------- 1 file changed, 18 insertions(+), 141 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1da9fe22..84d43ded 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,150 +1,27 @@ # AGENTS.md -## Development Workflow +## Workflow -**Plan Mode**: When in plan mode, interview thoroughly before coding: -- Ask detailed questions about technical implementation, UI/UX concerns, tradeoffs, and edge cases -- Continue asking questions until all requirements and non-obvious decisions are clear -- Don't begin implementation until all important details are resolved +- In plan mode, interview thoroughly — ask about UI/UX, tradeoffs, and edge cases before coding +- When new code supersedes existing functionality, find and remove everything it makes redundant +- Favor parallel tool calls and subagents when tasks are independent -**Pattern Consistency**: When implementing new code: -- Search the codebase to find existing usages and implementations -- Prefer following established patterns, styles, and practices for consistency +## Architecture (non-obvious bits) -**Dead Code Cleanup**: When new code replaces or supersedes existing functionality: -- Identify and remove anything the new code makes redundant -- Clean up any remaining references or imports to the removed code +- Screens live in `lib/screens/{feature}/` with co-located `stores/` subdirectories — don't create top-level store files +- All API services extend `BaseApiClient` (`lib/apis/base_api_client.dart`) — don't create standalone API classes +- MobX stores use a generated mixin pattern — see `lib/screens/settings/stores/auth_store.dart` for the canonical example -**Parallelization**: Favor parallel tool calls and subagents whenever tasks are independent (e.g., reading multiple files, running searches, exploring different parts of the codebase). Don't do things sequentially when they can be done concurrently. +## Gotchas -## Development Commands +- Run requires Twitch credentials: `flutter run --dart-define=clientId=YOUR_ID --dart-define=secret=YOUR_SECRET` +- After changing MobX stores or `@JsonSerializable` models, run `dart run build_runner build`. Never edit `.g.dart` files directly. Use `--delete-conflicting-outputs` if the build fails on stale generated files. Commit `.g.dart` files to source control. +- The secure storage cleanup in `main.dart` looks unnecessary but handles an Android/iOS edge case where secure storage persists after uninstall. Don't remove it. +- Use package imports (`import 'package:frosty/...'`), not relative imports +- Always include trailing commas +- Use single quotes +- Run `flutter analyze` after making changes. Run `flutter test` if changes touch testable logic. -**Common Commands:** -- `flutter pub get` - Install dependencies -- `flutter run --dart-define=clientId=YOUR_CLIENT_ID --dart-define=secret=YOUR_CLIENT_SECRET` - Run with Twitch API credentials -- `flutter analyze` - Run static analysis and lint checks -- `flutter test` - Run all tests -- `flutter test test/path/to/test.dart` - Run a single test file -- `flutter build apk` / `flutter build ios` - Build release binaries +## Commits -**Code Generation** (required after changing MobX stores or `@JsonSerializable` models): -- `dart run build_runner build` - Generate once -- `dart run build_runner build --delete-conflicting-outputs` - Force rebuild all -- `dart run build_runner watch` - Watch mode for development - -Generated `.g.dart` files are excluded from linting but must be committed to source control. Never edit `.g.dart` files directly — they are overwritten by build_runner. - -## Architecture Overview - -**State Management**: MobX with code generation. All stores end with `Store` and have corresponding `.g.dart` generated files. - -**Key Directories**: -- `lib/screens/` - UI screens organized by feature (home, channel, settings, onboarding) -- `lib/models/` - Data models with JSON serialization (uses .g.dart files) -- `lib/apis/` - API services for Twitch, BTTV, FFZ, and 7TV -- `lib/widgets/` - Reusable UI components -- `lib/utils/` - Utility modules including context extensions -- `lib/services/` - Application services (e.g., shared timer service) - -**Screen Organization**: `lib/screens/{feature}/` contains `{feature}.dart` (main UI), `stores/` subdirectory with MobX stores, and feature sub-components. - -**Main Application Flow**: -1. App starts in `main.dart` with Firebase initialization and dependency injection via Provider -2. Authentication handled by `AuthStore` using Twitch OAuth -3. Settings persisted via `SettingsStore` with SharedPreferences and MobX `autorun()` -4. API services share a common Dio HTTP client for efficient connection reuse - -**Global Stores** (injected via Provider in `main.dart`): -- `AuthStore` (`lib/screens/settings/stores/`) - Authentication state and token management -- `SettingsStore` (`lib/screens/settings/stores/`) - User preferences with automatic persistence via MobX `autorun()` -- `UserStore` (`lib/screens/settings/stores/`) - Current user profile data -- `GlobalAssetsStore` (`lib/stores/`) - Shared cache for global emotes and badges across all chat tabs - -## HTTP & API Architecture - -**Shared HTTP Client**: Single Dio instance configured in `DioClient.createClient()` with: -- Connection pooling and keep-alive headers for efficiency -- Optimized timeouts: 8s connect, 15s receive, 10s send -- Frosty User-Agent header for Twitch API compatibility - -**Authentication Interceptor**: `TwitchAuthInterceptor` automatically injects auth headers for Twitch API URLs. `UnauthorizedInterceptor` catches 401 errors for token refresh. - -**Two-tier Token System**: Default app token for unauthenticated requests + optional user token in Flutter Secure Storage. - -**BaseApiClient Pattern**: All API services (`TwitchApi`, `BTTVApi`, `FFZApi`, `SevenTVApi`) extend `BaseApiClient` which provides: -- Generic GET/POST/PUT/DELETE methods with centralized error handling -- Automatic conversion of `DioException` to typed exceptions - -**Exception Hierarchy**: `ApiException` base class with `NetworkException`, `TimeoutException`, `ServerException`, `NotFoundException`, `UnauthorizedException` (see `lib/apis/base_api_client.dart`). - -## MobX Store Implementation - -**Store Pattern**: All stores follow `StoreBase with _$StoreName` pattern: - -```dart -class SomeStore = SomeStoreBase with _$SomeStore; - -abstract class SomeStoreBase with Store { - @observable - var someValue = ''; - - @action - void updateValue(String value) => someValue = value; - - @computed - String get derivedValue => someValue.toUpperCase(); -} -``` - -**MobX Reactions** (for side effects and persistence): - -```dart -// Auto-save settings whenever they change (in main.dart) -autorun((_) => prefs.setString('settings', jsonEncode(settingsStore))); - -// React to specific state changes -reaction((_) => authStore.isLoggedIn, (_) => _selectedIndex = 0); -``` - -## Chat System Architecture - -- **Real-time IRC**: WebSocket connection to Twitch IRC with custom `IRCMessage` parsing -- **Third-party Emotes**: Asynchronous loading of BTTV, FFZ, and 7TV assets via dedicated APIs -- **Message Management**: 5000 message limit with 20% batch removal optimization -- **Assets Store**: `ChatAssetsStore` manages channel-specific emotes/badges -- **User Interaction**: Blocking, reporting, and moderation capabilities - -## Additional Patterns - -**Emote Architecture**: Base `Emote` class with platform-specific factories: `Emote.fromTwitch()`, `Emote.fromBTTV()`, `Emote.fromFFZ()`, `Emote.from7TV()`. - -**Custom Cache Manager**: Uses 30-day stale period and 10k max objects. `CustomCacheManager.removeOrphanedCacheFiles()` runs on startup to clean files not in database. - -**Secure Storage Cleanup**: First-run detection clears Flutter Secure Storage to handle Android/iOS uninstall edge case where secure storage persists but app data is wiped. - -**Authentication Flow**: -- OAuth via WebView with custom JavaScript injection for seamless Twitch login -- Token storage in FlutterSecureStorage -- Auto-refresh and validation logic in `AuthStore` - -## Code Style - -**Common lint mistakes to avoid:** -- Always use package imports, not relative imports (`import 'package:frosty/...'`) -- Always include trailing commas in argument lists and widget trees -- Use single quotes for strings - -Full lint rules are enforced by `flutter analyze` — run it after making changes to catch issues early. - -## Testing - -Tests live in `test/` mirroring the `lib/` structure: -- `test/models/` - Model unit tests (badges, emotes, IRC parsing) -- `test/fixtures/` - Shared test data (e.g., IRC message fixtures) -- `test/regex_test.dart`, `test/utils_test.dart` - Utility tests - -Run with `flutter test` or `flutter test test/path/to/file.dart` for a single file. - -## Commit Convention - -Commits use lowercase, descriptive messages without prefixes (e.g., `fix landscape bottom padding in chat bottom bar`, `add swipe pip gesture support`). Keep commits tightly scoped. +Lowercase, no prefixes (e.g., `fix landscape bottom padding in chat bottom bar`). Keep commits tightly scoped. From 93cd39c99f4689cddcad6beaf39f97703c431f47 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sat, 7 Feb 2026 23:18:54 -0800 Subject: [PATCH 005/126] consolidate overlay shadow constants, simplify overlay gradients Co-Authored-By: Claude Opus 4.6 --- .../channel/video/stream_info_bar.dart | 26 +--- lib/screens/channel/video/video_overlay.dart | 125 +++++------------- lib/theme.dart | 9 ++ 3 files changed, 45 insertions(+), 115 deletions(-) diff --git a/lib/screens/channel/video/stream_info_bar.dart b/lib/screens/channel/video/stream_info_bar.dart index 984ccf49..8673eb3a 100644 --- a/lib/screens/channel/video/stream_info_bar.dart +++ b/lib/screens/channel/video/stream_info_bar.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:frosty/models/channel.dart'; import 'package:frosty/models/stream.dart'; import 'package:frosty/screens/home/top/categories/category_streams.dart'; +import 'package:frosty/theme.dart'; import 'package:frosty/utils.dart'; import 'package:frosty/utils/context_extensions.dart'; import 'package:frosty/widgets/live_indicator.dart'; @@ -45,21 +46,6 @@ class StreamInfoBar extends StatelessWidget { this.displayName, }); - static const _iconShadow = [ - Shadow( - offset: Offset(0, 1), - blurRadius: 4, - color: Color.fromRGBO(0, 0, 0, 0.3), - ), - ]; - - static const _textShadow = [ - Shadow( - offset: Offset(0, 1), - blurRadius: 4, - color: Color.fromRGBO(0, 0, 0, 0.3), - ), - ]; TextStyle _getBaseTextStyle( BuildContext context, @@ -70,7 +56,7 @@ class StreamInfoBar extends StatelessWidget { fontSize: fontSize, fontWeight: fontWeight, color: textColor, - shadows: showTextShadows ? _textShadow : null, + shadows: showTextShadows ? kOverlayShadow : null, ) ?? const TextStyle(); } @@ -82,7 +68,7 @@ class StreamInfoBar extends StatelessWidget { color: textColor?.withValues(alpha: 0.7) ?? context.bodySmallColor?.withValues(alpha: 0.7), - shadows: showTextShadows ? _textShadow : null, + shadows: showTextShadows ? kOverlayShadow : null, ) ?? const TextStyle(); } @@ -199,7 +185,7 @@ class StreamInfoBar extends StatelessWidget { size: secondLineSize, color: (textColor ?? context.bodySmallColor) ?.withValues(alpha: 0.7), - shadows: _iconShadow, + shadows: kOverlayShadow, ), const SizedBox(width: 4), Flexible( @@ -270,7 +256,7 @@ class StreamInfoBar extends StatelessWidget { Icons.visibility, size: secondLineSize, color: textColor ?? context.bodySmallColor, - shadows: _iconShadow, + shadows: kOverlayShadow, ), const SizedBox(width: 4), Text( @@ -295,7 +281,7 @@ class StreamInfoBar extends StatelessWidget { Icons.gamepad, size: secondLineSize, color: textColor ?? context.bodySmallColor, - shadows: _iconShadow, + shadows: kOverlayShadow, ), const SizedBox(width: 4), Flexible( diff --git a/lib/screens/channel/video/video_overlay.dart b/lib/screens/channel/video/video_overlay.dart index 0ab9eafb..4b50dcbe 100644 --- a/lib/screens/channel/video/video_overlay.dart +++ b/lib/screens/channel/video/video_overlay.dart @@ -33,21 +33,21 @@ class VideoOverlay extends StatelessWidget { required this.settingsStore, }); - static const _iconShadow = [ - Shadow( - offset: Offset(0, 1), - blurRadius: 4, - color: Color.fromRGBO(0, 0, 0, 0.3), - ), - ]; - - static const _textShadow = [ - Shadow( - offset: Offset(0, 1), - blurRadius: 4, - color: Color.fromRGBO(0, 0, 0, 0.3), - ), - ]; + static BoxDecoration _overlayGradient({required bool fromTop}) => + BoxDecoration( + gradient: LinearGradient( + begin: fromTop ? Alignment.topCenter : Alignment.bottomCenter, + end: fromTop ? Alignment.bottomCenter : Alignment.topCenter, + colors: const [ + Colors.black, + Color.fromRGBO(0, 0, 0, 0.78), + Color.fromRGBO(0, 0, 0, 0.48), + Color.fromRGBO(0, 0, 0, 0.12), + Colors.transparent, + ], + stops: const [0.0, 0.25, 0.5, 0.8, 1.0], + ), + ); @override Widget build(BuildContext context) { @@ -62,7 +62,7 @@ class VideoOverlay extends StatelessWidget { icon: Icon( Icons.adaptive.arrow_back_rounded, color: surfaceColor, - shadows: _iconShadow, + shadows: kOverlayShadow, ), onPressed: Navigator.of(context).pop, ); @@ -75,14 +75,14 @@ class VideoOverlay extends StatelessWidget { onPressed: () => videoStore.settingsStore.fullScreenChatOverlay = !videoStore.settingsStore.fullScreenChatOverlay, icon: videoStore.settingsStore.fullScreenChatOverlay - ? Icon(Icons.chat_rounded, shadows: _iconShadow) - : Icon(Icons.chat_outlined, shadows: _iconShadow), + ? Icon(Icons.chat_rounded, shadows: kOverlayShadow) + : Icon(Icons.chat_outlined, shadows: kOverlayShadow), color: surfaceColor, ), ); final videoSettingsButton = IconButton( - icon: Icon(Icons.settings, shadows: _iconShadow), + icon: Icon(Icons.settings, shadows: kOverlayShadow), color: surfaceColor, onPressed: () { videoStore.updateStreamQualities(); @@ -138,7 +138,7 @@ class VideoOverlay extends StatelessWidget { icon: Icon( Icons.refresh_rounded, color: surfaceColor, - shadows: _iconShadow, + shadows: kOverlayShadow, ), onPressed: videoStore.handleRefresh, ), @@ -155,7 +155,7 @@ class VideoOverlay extends StatelessWidget { ? Icons.fullscreen_exit_rounded : Icons.fullscreen_rounded, color: surfaceColor, - shadows: _iconShadow, + shadows: kOverlayShadow, ), onPressed: () => videoStore.settingsStore.fullScreen = !videoStore.settingsStore.fullScreen, @@ -171,7 +171,7 @@ class VideoOverlay extends StatelessWidget { icon: Icon( Icons.screen_rotation_rounded, color: surfaceColor, - shadows: _iconShadow, + shadows: kOverlayShadow, ), onPressed: () async { if (context.isPortrait) { @@ -221,73 +221,8 @@ class VideoOverlay extends StatelessWidget { final streamInfo = videoStore.streamInfo; final offlineChannelInfo = videoStore.offlineChannelInfo; - // Top gradient - fades from top to bottom, covers top cluster area - final topGradient = BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black, // Solid black for controls - Colors.black.withValues(alpha: 0.95), // Strong coverage - Colors.black.withValues(alpha: 0.88), // Still very strong - Colors.black.withValues(alpha: 0.78), // Strong transition - Colors.black.withValues(alpha: 0.65), // Begin smooth fade - Colors.black.withValues(alpha: 0.48), // Faster fade - Colors.black.withValues(alpha: 0.32), // Quick transition - Colors.black.withValues(alpha: 0.18), // Rapid fade - Colors.black.withValues(alpha: 0.08), // Very light - Colors.black.withValues(alpha: 0.02), // Nearly gone - Colors.transparent, // Transparent end - ], - stops: [ - 0.0, // Top: Full black - solid area for controls - 0.1, // Maintain strong coverage for readability - 0.2, // Still strong black - 0.3, // Begin gradual fade - 0.42, // Smooth transition - 0.52, // Faster fade point - 0.62, // Quick transition - 0.7, // Rapid fade - 0.8, // Very light - 0.9, // Nearly gone - 1.0, // Bottom: Fully transparent - ], - ), - ); - - // Bottom gradient - fades from bottom to top, covers bottom cluster area - final bottomGradient = BoxDecoration( - gradient: LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - colors: [ - Colors.black, // Solid black for controls - Colors.black.withValues(alpha: 0.95), // Strong coverage - Colors.black.withValues(alpha: 0.88), // Still very strong - Colors.black.withValues(alpha: 0.78), // Strong transition - Colors.black.withValues(alpha: 0.65), // Begin smooth fade - Colors.black.withValues(alpha: 0.48), // Faster fade - Colors.black.withValues(alpha: 0.32), // Quick transition - Colors.black.withValues(alpha: 0.18), // Rapid fade - Colors.black.withValues(alpha: 0.08), // Very light - Colors.black.withValues(alpha: 0.02), // Nearly gone - Colors.transparent, // Transparent end - ], - stops: [ - 0.0, // Bottom: Full black - solid area for controls - 0.1, // Maintain strong coverage for readability - 0.2, // Still strong black - 0.3, // Begin gradual fade - 0.42, // Smooth transition - 0.52, // Faster fade point - 0.62, // Quick transition - 0.7, // Rapid fade - 0.8, // Very light - 0.9, // Nearly gone - 1.0, // Top: Fully transparent - ], - ), - ); + final topGradient = _overlayGradient(fromTop: true); + final bottomGradient = _overlayGradient(fromTop: false); if (streamInfo == null) { return Stack( @@ -465,7 +400,7 @@ class VideoOverlay extends StatelessWidget { style: TextStyle( color: surfaceColor, fontWeight: FontWeight.w500, - shadows: _textShadow, + shadows: kOverlayShadow, ), ), ], @@ -495,7 +430,7 @@ class VideoOverlay extends StatelessWidget { Icon( Icons.visibility, size: 14, - shadows: _iconShadow, + shadows: kOverlayShadow, color: surfaceColor, ), Text( @@ -508,7 +443,7 @@ class VideoOverlay extends StatelessWidget { fontFeatures: const [ FontFeature.tabularFigures(), ], - shadows: _textShadow, + shadows: kOverlayShadow, ), ), ], @@ -527,7 +462,7 @@ class VideoOverlay extends StatelessWidget { Icons.speed_rounded, size: 14, color: surfaceColor, - shadows: _iconShadow, + shadows: kOverlayShadow, ), Observer( builder: (context) => Text( @@ -538,7 +473,7 @@ class VideoOverlay extends StatelessWidget { fontFeatures: const [ FontFeature.tabularFigures(), ], - shadows: _textShadow, + shadows: kOverlayShadow, ), ), ), @@ -566,7 +501,7 @@ class VideoOverlay extends StatelessWidget { ? Icons.picture_in_picture_alt_outlined : Icons.picture_in_picture_alt_rounded, color: surfaceColor, - shadows: _iconShadow, + shadows: kOverlayShadow, ), onPressed: videoStore.togglePictureInPicture, ), diff --git a/lib/theme.dart b/lib/theme.dart index c70c12c5..aa0ba153 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -2,6 +2,15 @@ import 'dart:io'; import 'package:flutter/material.dart'; +/// Shared shadow for overlay controls and text on top of video content. +const kOverlayShadow = [ + Shadow( + offset: Offset(0, 1), + blurRadius: 4, + color: Color.fromRGBO(0, 0, 0, 0.3), + ), +]; + class FrostyThemes { final Color colorSchemeSeed; From a3b8ac3237e2a0403c650810c7a6eb6de83bdcca Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sat, 7 Feb 2026 23:19:02 -0800 Subject: [PATCH 006/126] clean up and harden webview video JS infrastructure Co-Authored-By: Claude Opus 4.6 --- lib/screens/channel/video/video_store.dart | 167 ++++++++++++--------- 1 file changed, 92 insertions(+), 75 deletions(-) diff --git a/lib/screens/channel/video/video_store.dart b/lib/screens/channel/video/video_store.dart index 2d2573b8..ca9579a1 100644 --- a/lib/screens/channel/video/video_store.dart +++ b/lib/screens/channel/video/video_store.dart @@ -241,10 +241,28 @@ abstract class VideoStoreBase with Store { // Initialize the [_overlayTimer] to auto-hide the overlay after a delay (default 5 seconds). _scheduleOverlayHide(); - // Initialize a reaction that will reload the webview whenever the overlay is toggled. + // Initialize a reaction that incrementally applies/removes overlay customizations + // without reloading the webview (which would tear down the HLS stream). _disposeOverlayReaction = reaction( (_) => settingsStore.showOverlay, - (_) => videoWebViewController.loadRequest(Uri.parse(videoUrl)), + (showOverlay) async { + if (showOverlay) { + await _hideDefaultOverlay(); + if (settingsStore.showLatency || settingsStore.autoSyncChatDelay) { + await _listenOnLatencyChanges(); + } + await updateStreamQualities(); + } else { + _showDefaultOverlay(); + try { + videoWebViewController.runJavaScript( + 'window._latencyTracker?.stop()', + ); + } catch (e) { + debugPrint(e.toString()); + } + } + }, ); // Initialize a reaction to manage stream info timer based on video mode @@ -321,26 +339,33 @@ abstract class VideoStoreBase with Store { try { await videoWebViewController.runJavaScript(r''' _queuePromise(async () => { - // Open the settings → quality submenu - (await _asyncQuerySelector('[data-a-target="player-settings-button"]')).click(); - (await _asyncQuerySelector('[data-a-target="player-settings-menu-item-quality"]')).click(); + const settingsBtn = await _asyncQuerySelector('[data-a-target="player-settings-button"]'); + if (!settingsBtn) return; - // Wait until at least one quality option is rendered - await _asyncQuerySelector( - '[data-a-target="player-settings-menu"] input[name="player-settings-submenu-quality-option"] + label' - ); + try { + settingsBtn.click(); - // Grab every label, normalise whitespace, return as array - const qualities = Array.from( - document.querySelectorAll( + const qualityItem = await _asyncQuerySelector('[data-a-target="player-settings-menu-item-quality"]'); + if (!qualityItem) return; + qualityItem.click(); + + // Wait until at least one quality option is rendered + await _asyncQuerySelector( '[data-a-target="player-settings-menu"] input[name="player-settings-submenu-quality-option"] + label' - ) - ).map(l => l.textContent.replace(/\s+/g, ' ').trim()); + ); - StreamQualities.postMessage(JSON.stringify(qualities)); + // Grab every label, normalise whitespace, return as array + const qualities = Array.from( + document.querySelectorAll( + '[data-a-target="player-settings-menu"] input[name="player-settings-submenu-quality-option"] + label' + ) + ).map(l => l.textContent.replace(/\s+/g, ' ').trim()); - // Close the settings panel again - (await _asyncQuerySelector('[data-a-target="player-settings-button"]')).click(); + StreamQualities.postMessage(JSON.stringify(qualities)); + } finally { + // Always close the settings panel + settingsBtn.click(); + } }); '''); } catch (e) { @@ -362,11 +387,24 @@ abstract class VideoStoreBase with Store { try { await videoWebViewController.runJavaScript(''' _queuePromise(async () => { - (await _asyncQuerySelector('[data-a-target="player-settings-button"]')).click(); - (await _asyncQuerySelector('[data-a-target="player-settings-menu-item-quality"]')).click(); - await _asyncQuerySelector('[data-a-target="player-settings-submenu-quality-option"] input'); - [...document.querySelectorAll('[data-a-target="player-settings-submenu-quality-option"] input')][$newStreamQualityIndex].click(); - (await _asyncQuerySelector('[data-a-target="player-settings-button"]')).click(); + const settingsBtn = await _asyncQuerySelector('[data-a-target="player-settings-button"]'); + if (!settingsBtn) return; + + try { + settingsBtn.click(); + + const qualityItem = await _asyncQuerySelector('[data-a-target="player-settings-menu-item-quality"]'); + if (!qualityItem) return; + qualityItem.click(); + + await _asyncQuerySelector('[data-a-target="player-settings-submenu-quality-option"] input'); + const inputs = [...document.querySelectorAll('[data-a-target="player-settings-submenu-quality-option"] input')]; + if (inputs[$newStreamQualityIndex]) { + inputs[$newStreamQualityIndex].click(); + } + } finally { + settingsBtn.click(); + } }); '''); _streamQualityIndex = newStreamQualityIndex; @@ -377,11 +415,8 @@ abstract class VideoStoreBase with Store { /// Hides the default Twitch overlay elements using CSS injection. /// - /// This approach is far more efficient than JavaScript DOM manipulation: - /// - CSS rules are applied by the browser's native rendering engine - /// - No MutationObserver overhead watching the entire DOM tree - /// - Handles dynamically added elements automatically via CSS cascade - /// - Single one-time observer that disconnects immediately after player loads + /// CSS rules apply automatically to present and future DOM elements, + /// so no MutationObserver is needed. Future _hideDefaultOverlay() async { try { await videoWebViewController.runJavaScript(''' @@ -402,18 +437,6 @@ abstract class VideoStoreBase with Store { `; document.head.appendChild(style); } - - // Single one-time observer just to detect when player loads - // Disconnects immediately - CSS handles everything after - const observer = new MutationObserver((_, obs) => { - if (document.querySelector('.video-player__overlay')) { - obs.disconnect(); - } - }); - observer.observe(document.body, { childList: true, subtree: true }); - - // Safety timeout: disconnect observer if player never loads - setTimeout(() => observer.disconnect(), 30000); } '''); } catch (e) { @@ -421,6 +444,18 @@ abstract class VideoStoreBase with Store { } } + /// Removes the custom CSS that hides Twitch's default overlay, + /// restoring the native Twitch player controls. + void _showDefaultOverlay() { + try { + videoWebViewController.runJavaScript( + "document.getElementById('frosty-overlay-styles')?.remove();", + ); + } catch (e) { + debugPrint(e.toString()); + } + } + Future _acceptContentWarning() async { try { await videoWebViewController.runJavaScript(''' @@ -496,22 +531,27 @@ abstract class VideoStoreBase with Store { // Skip cycle entirely if overlay not visible - major CPU savings if (!this.overlayVisible) { - // Check again in 5 seconds in case overlay becomes visible this.timeoutId = setTimeout(() => this._cycle(), 5000); return; } + // Defer if another operation (e.g., quality selection) is using the settings menu + if (window._promiseQueueLength > 0) { + this.timeoutId = setTimeout(() => this._cycle(), 2000); + return; + } + this.cycleCount++; const cycleStart = Date.now(); - await this._enableStats(); + await this._setStatsEnabled(true); // Wait longer on first cycle for stats to populate const waitTime = this.cycleCount === 1 ? 3000 : this.STATS_ACTIVE_TIME; await new Promise(resolve => setTimeout(resolve, waitTime)); this._readLatency(); - await this._disableStats(); + await this._setStatsEnabled(false); const totalActiveTime = Date.now() - cycleStart; @@ -527,7 +567,7 @@ abstract class VideoStoreBase with Store { this.timeoutId = setTimeout(() => this._cycle(), Math.max(nextInterval, 1000)); }, - async _enableStats() { + async _setStatsEnabled(enable) { try { await _queuePromise(async () => { const settingsBtn = await _asyncQuerySelector('[data-a-target="player-settings-button"]'); @@ -542,7 +582,7 @@ abstract class VideoStoreBase with Store { advancedItem.click(); const statsCheckbox = await _asyncQuerySelector('[data-a-target="player-settings-submenu-advanced-video-stats"] input'); - if (statsCheckbox && !statsCheckbox.checked) { + if (statsCheckbox && statsCheckbox.checked !== enable) { statsCheckbox.click(); } @@ -553,32 +593,6 @@ abstract class VideoStoreBase with Store { } }, - async _disableStats() { - try { - await _queuePromise(async () => { - const settingsBtn = await _asyncQuerySelector('[data-a-target="player-settings-button"]'); - if (!settingsBtn) return; - settingsBtn.click(); - - const advancedItem = await _asyncQuerySelector('[data-a-target="player-settings-menu-item-advanced"]'); - if (!advancedItem) { - settingsBtn.click(); - return; - } - advancedItem.click(); - - const statsCheckbox = await _asyncQuerySelector('[data-a-target="player-settings-submenu-advanced-video-stats"] input'); - if (statsCheckbox && statsCheckbox.checked) { - statsCheckbox.click(); - } - - settingsBtn.click(); - }); - } catch (error) { - // Silently fail - } - }, - _readLatency() { try { const latencyElement = document.querySelector('[aria-label="Latency To Broadcaster"]'); @@ -709,7 +723,7 @@ abstract class VideoStoreBase with Store { return window._PROMISE_QUEUE; }; - window._asyncQuerySelector = (selector, timeout = 30000) => new Promise((resolve) => { + window._asyncQuerySelector = (selector, timeout = 10000) => new Promise((resolve) => { let element = document.querySelector(selector); if (element) { return resolve(element); @@ -727,7 +741,7 @@ abstract class VideoStoreBase with Store { // Must use subtree to detect nested elements in the player observer.observe(document.body, { childList: true, subtree: true }); - // Always set timeout with default of 30 seconds + // Always set timeout with default of 10 seconds timeoutId = setTimeout(() => { observer.disconnect(); resolve(undefined); @@ -743,6 +757,9 @@ abstract class VideoStoreBase with Store { return; } + // Cache for reuse in play/pause/PiP calls + window._videoEl = videoElement; + // Prevent duplicate event listener registration if (!videoElement._listenersAdded) { videoElement._listenersAdded = true; @@ -961,11 +978,11 @@ abstract class VideoStoreBase with Store { try { if (_paused) { videoWebViewController.runJavaScript( - 'document.getElementsByTagName("video")[0].play();', + '(window._videoEl || document.getElementsByTagName("video")[0])?.play();', ); } else { videoWebViewController.runJavaScript( - 'document.getElementsByTagName("video")[0].pause();', + '(window._videoEl || document.getElementsByTagName("video")[0])?.pause();', ); } } catch (e) { @@ -983,7 +1000,7 @@ abstract class VideoStoreBase with Store { pip.enterPipMode(autoEnter: true); } else if (Platform.isIOS) { videoWebViewController.runJavaScript( - 'document.getElementsByTagName("video")[0].requestPictureInPicture();', + '(window._videoEl || document.getElementsByTagName("video")[0])?.requestPictureInPicture();', ); } } catch (e) { From a0b46eaf3da94f694d6c8e7d9ef47f87b46c284b Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sat, 7 Feb 2026 23:19:13 -0800 Subject: [PATCH 007/126] fix overlay toggle stream reload, fix spring-back animation listener Co-Authored-By: Claude Opus 4.6 --- lib/screens/channel/channel.dart | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/lib/screens/channel/channel.dart b/lib/screens/channel/channel.dart index bda8b7b7..a4fa6b76 100644 --- a/lib/screens/channel/channel.dart +++ b/lib/screens/channel/channel.dart @@ -91,14 +91,17 @@ class _VideoChatState extends State ); // Spring-back animation with smooth easing - _springBackAnimation = - Tween(begin: 0, end: 0).animate( - CurvedAnimation(parent: _animationController, curve: Curves.easeOut), - )..addListener(() { - setState(() { - _pipDragDistance = _springBackAnimation.value; - }); - }); + _springBackAnimation = Tween(begin: 0, end: 0).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeOut), + ); + + // Attach listener to the controller (not the derived animation) so it + // survives _springBackAnimation being reassigned in _animateSpringBack. + _animationController.addListener(() { + setState(() { + _pipDragDistance = _springBackAnimation.value; + }); + }); // Register as observer for app lifecycle events WidgetsBinding.instance.addObserver(this); @@ -201,7 +204,7 @@ class _VideoChatState extends State /// and an instructional overlay during the drag gesture. Widget _buildPipGestureWrapper({required Widget child, double? aspectRatio}) { return AnimatedBuilder( - animation: Listenable.merge([_animationController, _springBackAnimation]), + animation: _animationController, builder: (context, _) { final currentDragDistance = _isPipDragging ? _pipDragDistance @@ -321,9 +324,12 @@ class _VideoChatState extends State final video = Observer( builder: (context) { - if (!_videoStore.settingsStore.showOverlay) return player; - - return Stack(children: [player, overlay]); + return Stack( + children: [ + player, + if (_videoStore.settingsStore.showOverlay) overlay, + ], + ); }, ); From 68a6c57cf23d3bd39f1426431dfd0cbf107f320d Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sat, 7 Feb 2026 14:17:07 -0800 Subject: [PATCH 008/126] update android config: migrate gradle to kts, upgrade firebase plugins, fix edge-to-edge - migrate settings.gradle, build.gradle, app/build.gradle to kotlin dsl (.kts) - upgrade java/kotlin compatibility from 1.8 to 17 - upgrade firebase gradle plugins (google-services 4.4.4, crashlytics 3.0.6, perf 2.0.2) - remove jetifier, add gradle caching and parallel builds - fix windowDrawsSystemBarBackgrounds for edge-to-edge at targetSdk 36 - add dataExtractionRules for android 12+ backup compliance - use layout.buildDirectory api (replaces deprecated buildDir) Co-Authored-By: Claude Opus 4.6 --- android/app/build.gradle | 60 ------------------- android/app/build.gradle.kts | 60 +++++++++++++++++++ android/app/src/main/AndroidManifest.xml | 3 +- .../src/main/res/values-night-v31/styles.xml | 2 +- .../app/src/main/res/values-night/styles.xml | 2 +- .../app/src/main/res/values-v31/styles.xml | 2 +- android/app/src/main/res/values/styles.xml | 2 +- .../main/res/xml/data_extraction_rules.xml | 9 +++ android/build.gradle | 18 ------ android/build.gradle.kts | 24 ++++++++ android/gradle.properties | 3 +- android/settings.gradle | 28 --------- android/settings.gradle.kts | 28 +++++++++ 13 files changed, 129 insertions(+), 112 deletions(-) delete mode 100644 android/app/build.gradle create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/src/main/res/xml/data_extraction_rules.xml delete mode 100644 android/build.gradle create mode 100644 android/build.gradle.kts delete mode 100644 android/settings.gradle create mode 100644 android/settings.gradle.kts diff --git a/android/app/build.gradle b/android/app/build.gradle deleted file mode 100644 index d2a897d0..00000000 --- a/android/app/build.gradle +++ /dev/null @@ -1,60 +0,0 @@ -plugins { - id "com.android.application" - // START: FlutterFire Configuration - id 'com.google.gms.google-services' - id 'com.google.firebase.firebase-perf' - id 'com.google.firebase.crashlytics' - // END: FlutterFire Configuration - id "kotlin-android" - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. - id "dev.flutter.flutter-gradle-plugin" -} - -def keystoreProperties = new Properties() -def keystorePropertiesFile = rootProject.file("key.properties") -if (keystorePropertiesFile.exists()) { - keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) -} - -android { - namespace = "com.tommychow.frosty" - compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8 - } - - defaultConfig { - applicationId = "com.tommychow.frosty" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion - targetSdk = flutter.targetSdkVersion - versionCode = flutter.versionCode - versionName = flutter.versionName - } - - signingConfigs { - release { - keyAlias keystoreProperties["keyAlias"] - keyPassword keystoreProperties["keyPassword"] - storeFile keystoreProperties["storeFile"] ? file(keystoreProperties["storeFile"]) : null - storePassword keystoreProperties["storePassword"] - } - } - buildTypes { - release { - signingConfig signingConfigs.release - } - } -} - -flutter { - source = "../.." -} diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 00000000..c9f675d5 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,60 @@ +import java.io.FileInputStream +import java.util.Properties + +plugins { + id("com.android.application") + id("com.google.gms.google-services") + id("com.google.firebase.firebase-perf") + id("com.google.firebase.crashlytics") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + +android { + namespace = "com.tommychow.frosty" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + applicationId = "com.tommychow.frosty" + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + signingConfigs { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String? + keyPassword = keystoreProperties["keyPassword"] as String? + storeFile = keystoreProperties["storeFile"]?.let { file(it) } + storePassword = keystoreProperties["storePassword"] as String? + } + } + + buildTypes { + release { + signingConfig = signingConfigs.getByName("release") + } + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 7b2ba6ba..4beaec33 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -13,7 +13,8 @@ android:label="Frosty" android:name="${applicationName}" android:icon="@mipmap/ic_launcher" - android:allowBackup="false"> + android:allowBackup="false" + android:dataExtractionRules="@xml/data_extraction_rules"> false false - false + true shortEdges