diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 706067cc..520fe380 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -6,6 +6,16 @@ Frosty is a cross-platform Flutter app for Twitch that supports third-party emot ## 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 + ### Code Generation (REQUIRED) All MobX stores and JSON models require code generation. **Always run after any MobX or model changes:** @@ -14,14 +24,19 @@ All MobX stores and JSON models require code generation. **Always run after any flutter packages pub run build_runner build --delete-conflicting-outputs ``` -Generated `.g.dart` files are excluded from linting but must be committed. +**Alternative commands:** + +- `flutter packages pub run build_runner build` - Generate code once +- `flutter packages pub 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_CLIENT_ID --dart-define=secret=YOUR_SECRET +flutter run --dart-define=clientId=YOUR_TWITCH_CLIENT_ID --dart-define=secret=YOUR_TWITCH_CLIENT_SECRET ``` ## Architecture Patterns @@ -35,20 +50,50 @@ flutter run --dart-define=clientId=YOUR_CLIENT_ID --dart-define=secret=YOUR_SECR ### API Service Architecture -- **Shared HTTP Client**: Single `Client()` instance injected into all API services in `main.dart` +- **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/` -- **Headers Pattern**: Twitch API methods accept `headers` parameter for auth tokens +- **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 -├── {feature}_store.dart # Feature store (NOT at root) -├── {feature}_store.g.dart # Generated file +├── 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 @@ -69,12 +114,23 @@ Initialized in `main.dart` with error handling: - **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 @@ -84,6 +140,9 @@ Analysis rules in `analysis_options.yaml`: - 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` + +**Important**: `.g.dart` files are excluded from analysis but must be committed to source control. ## Common Patterns @@ -109,6 +168,37 @@ autorun((_) => prefs.setString('settings', jsonEncode(settingsStore))); Use `Future.error()` for API failures, not exceptions. +## MobX Store Implementation + +### Store Pattern + +All stores follow `StoreBase with _$StoreName` pattern: + +- Generate `.g.dart` files with `flutter packages pub 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 diff --git a/.gitignore b/.gitignore index 2c19596d..702a84bc 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ migrate_working_dir/ .pub-cache/ .pub/ /build/ +/coverage/ # Symbolication related app.*.symbols @@ -64,4 +65,5 @@ random/ **/fastlane/metadata/review_information # ai -.claude \ No newline at end of file +.claude +.cursor \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index f288a27e..883cbe35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,69 +2,131 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## Development Workflow + +**Plan Mode - In-Depth Planning**: 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 **Build 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 tests (note: no test suite currently exists) **Code Generation (Required for MobX stores and JSON serialization)** + - `flutter packages pub run build_runner build` - Generate code once - `flutter packages pub run build_runner watch` - Watch and regenerate code on changes - `flutter packages pub run build_runner build --delete-conflicting-outputs` - Force rebuild all generated files +**Asset Generation** + +- `flutter pub run flutter_native_splash:create` - Generate native splash screens +- `flutter pub run flutter_launcher_icons:main` - Generate app launcher icons + **Environment Variables** -Use `--dart-define` to set environment variables: -- `--dart-define=clientId=YOUR_TWITCH_CLIENT_ID` -- `--dart-define=secret=YOUR_TWITCH_CLIENT_SECRET` +Use `--dart-define` to set environment variables (required for Twitch API access): + +```bash +flutter run --dart-define=clientId=YOUR_TWITCH_CLIENT_ID --dart-define=secret=YOUR_TWITCH_CLIENT_SECRET +``` ## Architecture Overview -**State Management**: Uses MobX for reactive state management. All stores end with `Store` and have corresponding `.g.dart` generated files. Stores are located within their respective screen directories under `stores/` folders. +**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/cache_manager.dart` - Custom cache management for images/media -- `lib/utils.dart` - Utility functions and helpers +- `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 -4. API services inject common HTTP client for efficient connection reuse +3. Settings persisted via `SettingsStore` with SharedPreferences and MobX `autorun()` +4. API services share a common Dio HTTP client for efficient connection reuse -**Screen Architecture**: -- Home screen with tabs: Following streams, Top streams, Categories, Search -- Channel screen with video player and chat functionality -- Settings screen with account management and app configuration -- Onboarding flow for first-time users +**Global Stores** (provided at app root in `main.dart`): -**Chat System**: -- Real-time chat via WebSocket connection to Twitch IRC -- Third-party emote support (BTTV, FFZ, 7TV) loaded asynchronously -- Chat message parsing and rendering with emote/badge support -- User interaction features (blocking, reporting, moderation) +- `AuthStore` - Authentication state and token management +- `SettingsStore` - User preferences with automatic persistence -**Third-party Integrations**: -- Firebase: Crashlytics, Performance monitoring, Analytics -- Twitch API: Authentication, streams, users, chat -- BTTV/FFZ/7TV APIs: Extended emote and badge support +## HTTP Client Architecture -## Development Notes +**Centralized Dio Setup**: All API services share a single Dio instance configured in `DioClient.createClient()` with connection pooling, keep-alive headers, and optimized timeouts (8s connect, 15s receive, 10s send). -**MobX Code Generation**: This project heavily uses MobX for state management and JSON serialization. Any changes to MobX stores or model classes with `@JsonSerializable()` require running the build_runner to regenerate the corresponding `.g.dart` files. Generated files are excluded from analysis in `analysis_options.yaml`. +**Authentication Interceptor**: `TwitchAuthInterceptor` automatically injects auth headers for Twitch API URLs. Updates automatically when user authentication changes. -**API Configuration**: Requires Twitch Client ID and Client Secret from dev.twitch.tv console for API access. Use `--dart-define` flags when running the app. +**API Service Pattern**: All API services extend `BaseApiClient` for consistent error handling. Services: `TwitchApi`, `BTTVApi`, `FFZApi`, `SevenTVApi`. -**Code Style**: The project enforces specific lint rules including single quotes, trailing commas, final locals, and package imports. Run `flutter analyze` to check for violations. +## Authentication & Token Management -**Dependency Injection**: Uses Provider for dependency injection, with all major services (API clients, stores) provided at the app root in `main.dart` with a shared HTTP client for efficiency. +**Two-tier Token System**: -**Platform Support**: iOS and Android with platform-specific configurations. Uses Firebase for crashlytics, performance monitoring, and analytics. \ No newline at end of file +- Default app token for unauthenticated requests +- Optional user token stored in Flutter Secure Storage +- Automatic token validation on startup with fallback + +**Secure Storage Cleanup**: First-run detection clears secure storage to handle Android/iOS uninstall scenarios where secure storage persists. + +## 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(); +} +``` + +After any changes to MobX stores or `@JsonSerializable()` models, run the build_runner to regenerate `.g.dart` files. + +## 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**: Separate `ChatAssetsStore` manages emotes, badges, and chat-related data + +## Code Style + +Analysis rules enforced via `analysis_options.yaml`: + +- `prefer_single_quotes` - Use single quotes for strings +- `always_use_package_imports` - Package imports over relative imports +- `require_trailing_commas` - Trailing commas on multi-line constructs +- `prefer_final_locals` - Final for local variables +- `directives_ordering`, `avoid_void_async`, `always_declare_return_types` + +Generated `.g.dart` files are excluded from analysis but must be committed. + +## Commit Convention + +Commits follow conventional format: `refactor:`, `feat:`, `fix:`, `format:`, etc. Keep commits tightly scoped. Include generated `.g.dart` outputs in the same commit as their source changes. diff --git a/GEMINI.md b/GEMINI.md deleted file mode 100644 index a7153db2..00000000 --- a/GEMINI.md +++ /dev/null @@ -1,35 +0,0 @@ -# GEMINI.md - -## Project Overview - -This project is a Flutter-based mobile application called "Frosty", a third-party client for the streaming platform Twitch. It aims to provide a better user experience by integrating with popular third-party services like BetterTTV (BTTV), FrankerFaceZ (FFZ), and 7TV, which are not supported by the official Twitch app. - -The application is built using the Flutter framework and the Dart programming language. It uses Firebase for analytics and crash reporting, and MobX for state management. The app is available for both Android and iOS. - -## Building and Running - -To build and run the project, you will need to have Flutter installed. - -1. **Clone the repository:** - ```bash - git clone https://github.com/tommyxchow/frosty.git - ``` -2. **Set up Twitch API credentials:** - * Go to the [Twitch dev console](https://dev.twitch.tv/login) and register a new application to retrieve a **Client ID** and **Client Secret**. - * Use `--dart-define` to set the `clientId` and `secret` environment variables with your **Client ID** and **Client Secret**. -3. **Install dependencies:** - ```bash - flutter pub get - ``` -4. **Run the app:** - ```bash - flutter run - ``` - -## Development Conventions - -* **State Management:** The project uses MobX for state management. Code generation is required for MobX stores. -* **Code Style:** The project uses the `flutter_lints` package to enforce good coding practices. -* **Dependencies:** Dependencies are managed using the `pubspec.yaml` file. -* **Firebase:** The project uses Firebase for analytics and crash reporting. A `firebase_options.dart` file is used to configure the Firebase integration. -* **API Integration:** The application interacts with the Twitch API and other third-party APIs (BTTV, FFZ, 7TV) to fetch data. diff --git a/analysis_options.yaml b/analysis_options.yaml index 1b10d3d4..de45531d 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -11,6 +11,11 @@ include: package:flutter_lints/flutter.yaml analyzer: exclude: - lib/**.g.dart + - .dart_tool/** + - build/** + - ios/** + - android/** + - web/** linter: # The lint rules applied to this project can be customized in the diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d6b12e72..36beb448 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -36,15 +36,15 @@ - - - - - - - - - + + + + + + + + + CFBundleVersion 1.0 MinimumOSVersion - 12.0 + 13.0 diff --git a/ios/Podfile b/ios/Podfile index 279576f3..f17bddc9 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '12.0' +platform :ios, '15.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 02b8ed1d..847e60b6 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3,129 +3,130 @@ PODS: - Flutter - device_info_plus (0.0.1): - Flutter - - Firebase/Analytics (11.10.0): - - Firebase/Core - - Firebase/Core (11.10.0): + - Firebase/CoreOnly (12.2.0): + - FirebaseCore (~> 12.2.0) + - Firebase/Crashlytics (12.2.0): - Firebase/CoreOnly - - FirebaseAnalytics (~> 11.10.0) - - Firebase/CoreOnly (11.10.0): - - FirebaseCore (~> 11.10.0) - - Firebase/Crashlytics (11.10.0): + - FirebaseCrashlytics (~> 12.2.0) + - Firebase/Performance (12.2.0): - Firebase/CoreOnly - - FirebaseCrashlytics (~> 11.10.0) - - Firebase/Performance (11.10.0): - - Firebase/CoreOnly - - FirebasePerformance (~> 11.10.0) - - firebase_analytics (11.4.6): - - Firebase/Analytics (= 11.10.0) + - FirebasePerformance (~> 12.2.0) + - firebase_analytics (12.0.1): + - firebase_core + - FirebaseAnalytics (= 12.2.0) + - Flutter + - firebase_core (4.1.0): + - Firebase/CoreOnly (= 12.2.0) + - Flutter + - firebase_crashlytics (5.0.1): + - Firebase/Crashlytics (= 12.2.0) - firebase_core - Flutter - - firebase_core (3.13.1): - - Firebase/CoreOnly (= 11.10.0) - - Flutter - - firebase_crashlytics (4.3.6): - - Firebase/Crashlytics (= 11.10.0) + - firebase_performance (0.11.0-1): + - Firebase/Performance (= 12.2.0) - firebase_core - Flutter - - firebase_performance (0.10.1-6): - - Firebase/Performance (= 11.10.0) - - firebase_core - - Flutter - - FirebaseABTesting (11.10.0): - - FirebaseCore (~> 11.10.0) - - FirebaseAnalytics (11.10.0): - - FirebaseAnalytics/AdIdSupport (= 11.10.0) - - FirebaseCore (~> 11.10.0) - - FirebaseInstallations (~> 11.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.0) - - GoogleUtilities/MethodSwizzler (~> 8.0) - - GoogleUtilities/Network (~> 8.0) - - "GoogleUtilities/NSData+zlib (~> 8.0)" + - FirebaseABTesting (12.2.0): + - FirebaseCore (~> 12.2.0) + - FirebaseAnalytics (12.2.0): + - FirebaseAnalytics/Default (= 12.2.0) + - FirebaseCore (~> 12.2.0) + - FirebaseInstallations (~> 12.2.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - FirebaseAnalytics/AdIdSupport (11.10.0): - - FirebaseCore (~> 11.10.0) - - FirebaseInstallations (~> 11.0) - - GoogleAppMeasurement (= 11.10.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.0) - - GoogleUtilities/MethodSwizzler (~> 8.0) - - GoogleUtilities/Network (~> 8.0) - - "GoogleUtilities/NSData+zlib (~> 8.0)" + - FirebaseAnalytics/Default (12.2.0): + - FirebaseCore (~> 12.2.0) + - FirebaseInstallations (~> 12.2.0) + - GoogleAppMeasurement/Default (= 12.2.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - FirebaseCore (11.10.0): - - FirebaseCoreInternal (~> 11.10.0) - - GoogleUtilities/Environment (~> 8.0) - - GoogleUtilities/Logger (~> 8.0) - - FirebaseCoreExtension (11.10.0): - - FirebaseCore (~> 11.10.0) - - FirebaseCoreInternal (11.10.0): - - "GoogleUtilities/NSData+zlib (~> 8.0)" - - FirebaseCrashlytics (11.10.0): - - FirebaseCore (~> 11.10.0) - - FirebaseInstallations (~> 11.0) - - FirebaseRemoteConfigInterop (~> 11.0) - - FirebaseSessions (~> 11.0) - - GoogleDataTransport (~> 10.0) - - GoogleUtilities/Environment (~> 8.0) + - FirebaseCore (12.2.0): + - FirebaseCoreInternal (~> 12.2.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Logger (~> 8.1) + - FirebaseCoreExtension (12.2.0): + - FirebaseCore (~> 12.2.0) + - FirebaseCoreInternal (12.2.0): + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - FirebaseCrashlytics (12.2.0): + - FirebaseCore (~> 12.2.0) + - FirebaseInstallations (~> 12.2.0) + - FirebaseRemoteConfigInterop (~> 12.2.0) + - FirebaseSessions (~> 12.2.0) + - GoogleDataTransport (~> 10.1) + - GoogleUtilities/Environment (~> 8.1) - nanopb (~> 3.30910.0) - PromisesObjC (~> 2.4) - - FirebaseInstallations (11.10.0): - - FirebaseCore (~> 11.10.0) - - GoogleUtilities/Environment (~> 8.0) - - GoogleUtilities/UserDefaults (~> 8.0) + - FirebaseInstallations (12.2.0): + - FirebaseCore (~> 12.2.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) - PromisesObjC (~> 2.4) - - FirebasePerformance (11.10.0): - - FirebaseCore (~> 11.10.0) - - FirebaseInstallations (~> 11.0) - - FirebaseRemoteConfig (~> 11.0) - - FirebaseSessions (~> 11.0) - - GoogleDataTransport (~> 10.0) - - GoogleUtilities/Environment (~> 8.0) - - GoogleUtilities/MethodSwizzler (~> 8.0) - - GoogleUtilities/UserDefaults (~> 8.0) + - FirebasePerformance (12.2.0): + - FirebaseCore (~> 12.2.0) + - FirebaseInstallations (~> 12.2.0) + - FirebaseRemoteConfig (~> 12.2.0) + - FirebaseSessions (~> 12.2.0) + - GoogleDataTransport (~> 10.1) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) - nanopb (~> 3.30910.0) - - FirebaseRemoteConfig (11.10.0): - - FirebaseABTesting (~> 11.0) - - FirebaseCore (~> 11.10.0) - - FirebaseInstallations (~> 11.0) - - FirebaseRemoteConfigInterop (~> 11.0) - - FirebaseSharedSwift (~> 11.0) - - GoogleUtilities/Environment (~> 8.0) - - "GoogleUtilities/NSData+zlib (~> 8.0)" - - FirebaseRemoteConfigInterop (11.13.0) - - FirebaseSessions (11.10.0): - - FirebaseCore (~> 11.10.0) - - FirebaseCoreExtension (~> 11.10.0) - - FirebaseInstallations (~> 11.0) - - GoogleDataTransport (~> 10.0) - - GoogleUtilities/Environment (~> 8.0) - - GoogleUtilities/UserDefaults (~> 8.0) + - FirebaseRemoteConfig (12.2.0): + - FirebaseABTesting (~> 12.2.0) + - FirebaseCore (~> 12.2.0) + - FirebaseInstallations (~> 12.2.0) + - FirebaseRemoteConfigInterop (~> 12.2.0) + - FirebaseSharedSwift (~> 12.2.0) + - GoogleUtilities/Environment (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - FirebaseRemoteConfigInterop (12.2.0) + - FirebaseSessions (12.2.0): + - FirebaseCore (~> 12.2.0) + - FirebaseCoreExtension (~> 12.2.0) + - FirebaseInstallations (~> 12.2.0) + - GoogleDataTransport (~> 10.1) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) - nanopb (~> 3.30910.0) - PromisesSwift (~> 2.1) - - FirebaseSharedSwift (11.13.0) + - FirebaseSharedSwift (12.2.0) - Flutter (1.0.0) - flutter_native_splash (2.4.3): - Flutter - flutter_secure_storage (6.0.0): - Flutter - - GoogleAppMeasurement (11.10.0): - - GoogleAppMeasurement/AdIdSupport (= 11.10.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.0) - - GoogleUtilities/MethodSwizzler (~> 8.0) - - GoogleUtilities/Network (~> 8.0) - - "GoogleUtilities/NSData+zlib (~> 8.0)" + - GoogleAdsOnDeviceConversion (2.3.0): + - GoogleUtilities/Logger (~> 8.1) + - GoogleUtilities/Network (~> 8.1) - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/AdIdSupport (11.10.0): - - GoogleAppMeasurement/WithoutAdIdSupport (= 11.10.0) - - GoogleUtilities/AppDelegateSwizzler (~> 8.0) - - GoogleUtilities/MethodSwizzler (~> 8.0) - - GoogleUtilities/Network (~> 8.0) - - "GoogleUtilities/NSData+zlib (~> 8.0)" + - GoogleAppMeasurement/Core (12.2.0): + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/WithoutAdIdSupport (11.10.0): - - GoogleUtilities/AppDelegateSwizzler (~> 8.0) - - GoogleUtilities/MethodSwizzler (~> 8.0) - - GoogleUtilities/Network (~> 8.0) - - "GoogleUtilities/NSData+zlib (~> 8.0)" + - GoogleAppMeasurement/Default (12.2.0): + - GoogleAdsOnDeviceConversion (= 2.3.0) + - GoogleAppMeasurement/Core (= 12.2.0) + - GoogleAppMeasurement/IdentitySupport (= 12.2.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - GoogleAppMeasurement/IdentitySupport (12.2.0): + - GoogleAppMeasurement/Core (= 12.2.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - GoogleDataTransport (10.1.0): - nanopb (~> 3.30910.0) @@ -220,6 +221,7 @@ SPEC REPOS: - FirebaseRemoteConfigInterop - FirebaseSessions - FirebaseSharedSwift + - GoogleAdsOnDeviceConversion - GoogleAppMeasurement - GoogleDataTransport - GoogleUtilities @@ -266,30 +268,31 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: advanced_in_app_review: fe8fd50b8670cb3dbe67e4c26ca643f7aea8dbb8 device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 - Firebase: 1fe1c0a7d9aaea32efe01fbea5f0ebd8d70e53a2 - firebase_analytics: 943aa4699bf6afe056a96991ba8ee34776323d4c - firebase_core: 3c2f323cae65c97a636a05a23b17730ef93df2cf - firebase_crashlytics: 6f5f71ba7ec7b6a0412c4ed076272643c434f69f - firebase_performance: da79b34e2753057f7028c5e41ac5d8b3c5398eb2 - FirebaseABTesting: dfc10eb6cc08fe3b391ac9e5aa40396d43ea6675 - FirebaseAnalytics: 4e42333f02cf78ed93703a5c36f36dd518aebdef - FirebaseCore: 8344daef5e2661eb004b177488d6f9f0f24251b7 - FirebaseCoreExtension: 6f357679327f3614e995dc7cf3f2d600bdc774ac - FirebaseCoreInternal: ef4505d2afb1d0ebbc33162cb3795382904b5679 - FirebaseCrashlytics: 84b073c997235740e6a951b7ee49608932877e5c - FirebaseInstallations: 9980995bdd06ec8081dfb6ab364162bdd64245c3 - FirebasePerformance: f0bd14be05aaa1136cbb1c9aaaf87d213d0e6fbf - FirebaseRemoteConfig: 10695bc0ce3b103e3706a5578c43f2a9f69d5aaa - FirebaseRemoteConfigInterop: 7915cec47731a806cda541f90898ad0fab8f9f86 - FirebaseSessions: 9b3b30947b97a15370e0902ee7a90f50ef60ead6 - FirebaseSharedSwift: aca73668bc95e8efccb618e0167eab05d19d3a75 - Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + Firebase: 26f6f8d460603af3df970ad505b16b15f5e2e9a1 + firebase_analytics: cad607594fc56b3743691f37000d81cde0a86237 + firebase_core: 3b9065a1037f7f2e360077d2aa5fb6ec9adb3005 + firebase_crashlytics: 0d84e61860fcc67faf4d3a59664ce13a5622dbc0 + firebase_performance: 0e63765dc819d5f4b2331000c4f42382fc92a739 + FirebaseABTesting: 32f3fc079d72c9b93e000b60877c4e4f62ef7031 + FirebaseAnalytics: e04e23bc070e3014aa5cf4980f9df7ce5cd79ec8 + FirebaseCore: 311c48a147ad4a0ab7febbaed89e8025c67510cd + FirebaseCoreExtension: 73af080c22a2f7b44cefa391dc08f7e4ee162cb5 + FirebaseCoreInternal: 56ea29f3dad2894f81b060f706f9d53509b6ed3b + FirebaseCrashlytics: f83cbf176d5c637ade108c0aacf1ccbd5ec499bf + FirebaseInstallations: 3e884b01feabdf67582a80f3250425a00979b4ed + FirebasePerformance: 9071eb99f3ed135defc1c145391a82edb98e0b93 + FirebaseRemoteConfig: e3ae5500b92e813f3a0b12fa09701a26441dfc95 + FirebaseRemoteConfigInterop: 0896fd52ab72586a355c8f389ff85aaa9e5375e1 + FirebaseSessions: f4692789e770bec66ce17d772c0e9561c4f11737 + FirebaseSharedSwift: 52d868d4c269fcb4e4e1310c548435a9c1f46e25 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29 flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 - GoogleAppMeasurement: 36684bfb3ee034e2b42b4321eb19da3a1b81e65d + GoogleAdsOnDeviceConversion: 9090c435cde08903e8dd1ba2c77fbec9e46d9afe + GoogleAppMeasurement: 09f341dfa8527d1612a09cbfe809a242c0b737af GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 - in_app_review: a31b5257259646ea78e0e35fc914979b0031d011 + in_app_review: 436034b18594851a7328d7f1c2ed5ec235b79cfc nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 @@ -301,6 +304,6 @@ SPEC CHECKSUMS: wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e webview_flutter_wkwebview: a4af96a051138e28e29f60101d094683b9f82188 -PODFILE CHECKSUM: c4c93c5f6502fe2754f48404d3594bf779584011 +PODFILE CHECKSUM: 57c8aed26fba39d3ec9424816221f294a07c58eb COCOAPODS: 1.16.2 diff --git a/lib/apis/base_api_client.dart b/lib/apis/base_api_client.dart new file mode 100644 index 00000000..5c8a18e0 --- /dev/null +++ b/lib/apis/base_api_client.dart @@ -0,0 +1,248 @@ +import 'package:dio/dio.dart'; + +/// Common type aliases to reduce repetition +typedef JsonMap = Map; +typedef JsonList = List; +typedef QueryParams = Map; +typedef ApiHeaders = Map; + +/// Custom API exceptions for better error handling +class ApiException implements Exception { + final String message; + final int? statusCode; + final DioExceptionType? type; + + const ApiException(this.message, [this.statusCode, this.type]); + + @override + String toString() => 'ApiException: $message'; +} + +class NetworkException extends ApiException { + const NetworkException(String message) + : super(message, null, DioExceptionType.connectionError); +} + +class TimeoutException extends ApiException { + const TimeoutException(String message) + : super(message, null, DioExceptionType.connectionTimeout); +} + +class ServerException extends ApiException { + const ServerException(String message, int statusCode) + : super(message, statusCode, DioExceptionType.badResponse); +} + +class NotFoundException extends ApiException { + const NotFoundException(String message) + : super(message, 404, DioExceptionType.badResponse); +} + +class UnauthorizedException extends ApiException { + const UnauthorizedException(String message) + : super(message, 401, DioExceptionType.badResponse); +} + +/// Base API client to eliminate code duplication across API services +abstract class BaseApiClient { + final Dio _dio; + final String baseUrl; + + const BaseApiClient(this._dio, this.baseUrl); + + /// Builds the full URL, handling both relative endpoints and full URLs + String _buildUrl(String endpoint) { + // If endpoint is already a full URL (starts with http:// or https://), use it as-is + if (endpoint.startsWith('http://') || endpoint.startsWith('https://')) { + return endpoint; + } + // Otherwise, concatenate with base URL + return '$baseUrl$endpoint'; + } + + /// Generic GET request with common error handling + Future get( + String endpoint, { + QueryParams? queryParameters, + ApiHeaders? headers, + CancelToken? cancelToken, + }) async { + try { + final url = _buildUrl(endpoint); + final response = await _dio.get( + url, + queryParameters: queryParameters, + options: Options(headers: headers), + cancelToken: cancelToken, + ); + return response.data as T; + } on DioException catch (e) { + throw _handleError(e); + } + } + + /// Generic POST request with common error handling + Future post( + String endpoint, { + dynamic data, + QueryParams? queryParameters, + ApiHeaders? headers, + CancelToken? cancelToken, + }) async { + try { + final url = _buildUrl(endpoint); + final response = await _dio.post( + url, + data: data, + queryParameters: queryParameters, + options: Options(headers: headers), + cancelToken: cancelToken, + ); + return response.data as T; + } on DioException catch (e) { + throw _handleError(e); + } + } + + /// Generic PUT request with common error handling + Future put( + String endpoint, { + dynamic data, + QueryParams? queryParameters, + ApiHeaders? headers, + CancelToken? cancelToken, + }) async { + try { + final url = _buildUrl(endpoint); + final response = await _dio.put( + url, + data: data, + queryParameters: queryParameters, + options: Options(headers: headers), + cancelToken: cancelToken, + ); + return response.data as T; + } on DioException catch (e) { + throw _handleError(e); + } + } + + /// Generic DELETE request with common error handling + Future delete( + String endpoint, { + QueryParams? queryParameters, + ApiHeaders? headers, + CancelToken? cancelToken, + }) async { + try { + final url = _buildUrl(endpoint); + final response = await _dio.delete( + url, + queryParameters: queryParameters, + options: Options(headers: headers), + cancelToken: cancelToken, + ); + return response.data as T; + } on DioException catch (e) { + throw _handleError(e); + } + } + + /// Check if response indicates success (useful for status-only operations) + bool isSuccessResponse(Response response) { + return response.statusCode != null && + response.statusCode! >= 200 && + response.statusCode! < 300; + } + + /// Converts DioException to appropriate ApiException subtype + ApiException _handleError(DioException error) { + switch (error.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + return TimeoutException(_getTimeoutMessage(error.type)); + + case DioExceptionType.connectionError: + return NetworkException( + 'No internet connection. Please check your network.', + ); + + case DioExceptionType.badResponse: + return _handleHttpError(error); + + case DioExceptionType.cancel: + return ApiException('Request was cancelled'); + + case DioExceptionType.badCertificate: + return ApiException('Security certificate error'); + + case DioExceptionType.unknown: + return ApiException(error.message ?? 'An unexpected error occurred'); + } + } + + /// Gets specific timeout message based on timeout type + String _getTimeoutMessage(DioExceptionType type) { + switch (type) { + case DioExceptionType.connectionTimeout: + return 'Connection timeout. Please try again.'; + case DioExceptionType.sendTimeout: + return 'Upload timeout. Please try again.'; + case DioExceptionType.receiveTimeout: + return 'Server response timeout. Please try again.'; + default: + return 'Request timeout. Please try again.'; + } + } + + /// Handles HTTP status code errors with specific exception types + ApiException _handleHttpError(DioException error) { + final statusCode = error.response?.statusCode; + final responseData = error.response?.data; + + // Extract error message from server response + String? serverMessage; + if (responseData is JsonMap) { + serverMessage = + responseData['message'] as String? ?? + responseData['error'] as String? ?? + responseData['error_description'] as String?; + } + + switch (statusCode) { + case 400: + return ApiException(serverMessage ?? 'Invalid request data'); + case 401: + return UnauthorizedException('Please log in to continue'); + case 403: + return ApiException('Access denied. Insufficient permissions'); + case 404: + return NotFoundException(serverMessage ?? 'Resource not found'); + case 409: + return ApiException('Conflict with current state. Please refresh'); + case 422: + return ApiException(serverMessage ?? 'Invalid data provided'); + case 429: + return ApiException( + 'Rate limit exceeded. Please wait before trying again', + ); + case 500: + return ServerException('Server error. Please try again later', 500); + case 502: + return ServerException('Service temporarily unavailable', 502); + case 503: + return ServerException( + 'Service maintenance. Please try again later', + 503, + ); + case 504: + return ServerException('Server timeout. Please try again', 504); + default: + return ServerException( + serverMessage ?? 'HTTP Error $statusCode occurred', + statusCode ?? 500, + ); + } + } +} diff --git a/lib/apis/bttv_api.dart b/lib/apis/bttv_api.dart index cf47b28e..e65da2a2 100644 --- a/lib/apis/bttv_api.dart +++ b/lib/apis/bttv_api.dart @@ -1,75 +1,55 @@ -import 'dart:convert'; - +import 'package:dio/dio.dart'; +import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/models/badges.dart'; import 'package:frosty/models/emotes.dart'; -import 'package:http/http.dart'; /// The BTTV service for making API calls. -class BTTVApi { - final Client _client; - - const BTTVApi(this._client); +class BTTVApi extends BaseApiClient { + BTTVApi(Dio dio) : super(dio, 'https://api.betterttv.net/3/cached'); /// Returns a map of global BTTV emotes to their URL. Future> getEmotesGlobal() async { - final url = Uri.parse('https://api.betterttv.net/3/cached/emotes/global'); + final data = await get('/emotes/global'); - final response = await _client.get(url); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body) as List; - final emotes = decoded.map((emote) => EmoteBTTV.fromJson(emote)).toList(); + final emotes = data.map((emote) => EmoteBTTV.fromJson(emote)).toList(); - return emotes - .map((emote) => Emote.fromBTTV(emote, EmoteType.bttvGlobal)) - .toList(); - } else { - return Future.error('Failed to get BTTV global emotes'); - } + return emotes + .map((emote) => Emote.fromBTTV(emote, EmoteType.bttvGlobal)) + .toList(); } /// Returns a map of a channel's BTTV emotes to their URL. Future> getEmotesChannel({required String id}) async { - final url = - Uri.parse('https://api.betterttv.net/3/cached/users/twitch/$id'); + final data = await get('/users/twitch/$id'); - final response = await _client.get(url); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body); - final result = EmoteBTTVChannel.fromJson(decoded); + final result = EmoteBTTVChannel.fromJson(data); - final emoteToUrl = []; - emoteToUrl.addAll( - result.channelEmotes - .map((emote) => Emote.fromBTTV(emote, EmoteType.bttvChannel)), - ); - emoteToUrl.addAll( - result.sharedEmotes - .map((emote) => Emote.fromBTTV(emote, EmoteType.bttvShared)), - ); + final emoteToUrl = []; + emoteToUrl.addAll( + result.channelEmotes.map( + (emote) => Emote.fromBTTV(emote, EmoteType.bttvChannel), + ), + ); + emoteToUrl.addAll( + result.sharedEmotes.map( + (emote) => Emote.fromBTTV(emote, EmoteType.bttvShared), + ), + ); - return emoteToUrl; - } else { - return Future.error('Failed to get BTTV channel emotes'); - } + return emoteToUrl; } /// Returns a map of username to their BTTV badge. Future> getBadges() async { - final url = Uri.parse('https://api.betterttv.net/3/cached/badges'); + final data = await get('/badges'); - final response = await _client.get(url); - if (response.statusCode == 200) { - final badges = jsonDecode(response.body) as List; + final badgeObjects = data + .map((badge) => BadgeInfoBTTV.fromJson(badge)) + .toList(); - final badgeObjects = - badges.map((badge) => BadgeInfoBTTV.fromJson(badge)).toList(); - - return { - for (final badge in badgeObjects) - badge.providerId: ChatBadge.fromBTTV(badge), - }; - } else { - return Future.error('Failed to get BTTV badges'); - } + return { + for (final badge in badgeObjects) + badge.providerId: ChatBadge.fromBTTV(badge), + }; } } diff --git a/lib/apis/dio_client.dart b/lib/apis/dio_client.dart new file mode 100644 index 00000000..13cd2197 --- /dev/null +++ b/lib/apis/dio_client.dart @@ -0,0 +1,67 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; + +/// Centralized Dio client configuration with interceptors and error handling +class DioClient { + static Dio createClient({List? additionalInterceptors}) { + final dio = Dio(); + + // Optimized configuration based on 2024-2025 best practices + dio.options = BaseOptions( + connectTimeout: const Duration( + seconds: 8, + ), // Balanced for various networks + receiveTimeout: const Duration( + seconds: 15, + ), // Allow for larger API responses + sendTimeout: const Duration(seconds: 10), // Reasonable for uploads + headers: { + 'User-Agent': 'Frosty (Flutter Twitch Client)', + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Connection': 'keep-alive', // Enable connection reuse + }, + followRedirects: true, + maxRedirects: 3, // Reduced for better performance + persistentConnection: true, // Enable connection pooling + ); + + // Simple logging for debug builds + if (kDebugMode) { + dio.interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) { + debugPrint('→ ${options.method} ${options.uri}'); + handler.next(options); + }, + onResponse: (response, handler) { + final statusCode = response.statusCode ?? 0; + final emoji = statusCode >= 200 && statusCode < 300 ? '✅' : '❌'; + debugPrint( + '$emoji ${response.statusCode} ${response.requestOptions.method} ${response.requestOptions.uri}', + ); + handler.next(response); + }, + onError: (error, handler) { + debugPrint( + '❌ ${error.type.name} ${error.requestOptions.method} ${error.requestOptions.uri}', + ); + if (error.response != null) { + debugPrint(' Status: ${error.response!.statusCode}'); + } + handler.next(error); + }, + ), + ); + } + + // Add any additional interceptors provided + if (additionalInterceptors != null) { + for (final interceptor in additionalInterceptors) { + dio.interceptors.add(interceptor); + } + } + + return dio; + } +} diff --git a/lib/apis/ffz_api.dart b/lib/apis/ffz_api.dart index 18f3eef0..d1918dee 100644 --- a/lib/apis/ffz_api.dart +++ b/lib/apis/ffz_api.dart @@ -1,90 +1,69 @@ -import 'dart:convert'; - +import 'package:dio/dio.dart'; +import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/models/badges.dart'; import 'package:frosty/models/emotes.dart'; -import 'package:http/http.dart'; /// The FFZ service for making API calls. -class FFZApi { - final Client _client; - - const FFZApi(this._client); +class FFZApi extends BaseApiClient { + FFZApi(Dio dio) : super(dio, 'https://api.frankerfacez.com/v1'); /// Returns a map of global FFZ emotes to their URL. Future> getEmotesGlobal() async { - final url = Uri.parse('https://api.frankerfacez.com/v1/set/global'); + final data = await get('/set/global'); - final response = await _client.get(url); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body); - final defaultSets = decoded['default_sets'] as List; + final defaultSets = data['default_sets'] as JsonList; - final emotes = []; - for (final setId in defaultSets) { - final emoticons = - decoded['sets'][setId.toString()]['emoticons'] as List; - emotes.addAll(emoticons.map((emote) => EmoteFFZ.fromJson(emote))); - } - - return emotes - .map((emote) => Emote.fromFFZ(emote, EmoteType.ffzGlobal)) - .toList(); - } else { - return Future.error('Failed to get FFZ global emotes'); + final emotes = []; + for (final setId in defaultSets) { + final emoticons = data['sets'][setId.toString()]['emoticons'] as JsonList; + emotes.addAll(emoticons.map((emote) => EmoteFFZ.fromJson(emote))); } + + return emotes + .map((emote) => Emote.fromFFZ(emote, EmoteType.ffzGlobal)) + .toList(); } /// Returns a channel's FFZ room info including custom badges and emote used. Future<(RoomFFZ, List)> getRoomInfo({required String id}) async { - final url = Uri.parse('https://api.frankerfacez.com/v1/room/id/$id'); + final data = await get('/room/id/$id'); - final response = await _client.get(url); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body); - final roomInfo = RoomFFZ.fromJson(decoded['room']); - final emoticons = - decoded['sets'][roomInfo.set.toString()]['emoticons'] as List; + final roomInfo = RoomFFZ.fromJson(data['room']); + final emoticons = + data['sets'][roomInfo.set.toString()]['emoticons'] as JsonList; - final emotes = emoticons.map((emote) => EmoteFFZ.fromJson(emote)); + final emotes = emoticons.map((emote) => EmoteFFZ.fromJson(emote)); - return ( - roomInfo, - emotes - .map((emote) => Emote.fromFFZ(emote, EmoteType.ffzChannel)) - .toList() - ); - } else { - return Future.error('Failed to get FFZ room info'); - } + return ( + roomInfo, + emotes + .map((emote) => Emote.fromFFZ(emote, EmoteType.ffzChannel)) + .toList(), + ); } /// Returns a map of badges user IDs to a list of their FFZ badges. Future>> getBadges() async { - final url = Uri.parse('https://api.frankerfacez.com/v1/badges/ids'); + final data = await get('/badges/ids'); - final response = await _client.get(url); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body); - final badges = decoded['badges'] as List; - final badgeObjects = - badges.map((badge) => BadgeInfoFFZ.fromJson(badge)).toList(); + final badges = data['badges'] as JsonList; + final badgeObjects = badges + .map((badge) => BadgeInfoFFZ.fromJson(badge)) + .toList(); - final result = >{}; - for (final badge in badgeObjects.reversed) { - for (final userId in decoded['users'][badge.id.toString()]) { - final entry = result[userId.toString()]; - final normalBadge = ChatBadge.fromFFZ(badge); - if (entry == null) { - result[userId.toString()] = [normalBadge]; - } else { - entry.add(normalBadge); - } + final result = >{}; + for (final badge in badgeObjects.reversed) { + for (final userId in data['users'][badge.id.toString()]) { + final entry = result[userId.toString()]; + final normalBadge = ChatBadge.fromFFZ(badge); + if (entry == null) { + result[userId.toString()] = [normalBadge]; + } else { + entry.add(normalBadge); } } - - return result; - } else { - return Future.error('Failed to get FFZ badges'); } + + return result; } } diff --git a/lib/apis/seventv_api.dart b/lib/apis/seventv_api.dart index 1b93df5f..27d02a0f 100644 --- a/lib/apis/seventv_api.dart +++ b/lib/apis/seventv_api.dart @@ -1,52 +1,39 @@ -import 'dart:convert'; - +import 'package:dio/dio.dart'; +import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/models/emotes.dart'; -import 'package:http/http.dart'; /// The 7TV service for making API calls. -class SevenTVApi { - final Client _client; - - const SevenTVApi(this._client); +class SevenTVApi extends BaseApiClient { + SevenTVApi(Dio dio) : super(dio, 'https://7tv.io/v3'); /// Returns a map of global 7TV emotes to their URL. Future> getEmotesGlobal() async { - final url = Uri.parse('https://7tv.io/v3/emote-sets/global'); + final data = await get('/emote-sets/global'); - final response = await _client.get(url); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body)['emotes'] as List; - final emotes = decoded.map((emote) => Emote7TV.fromJson(emote)); + final decoded = data['emotes'] as JsonList; + final emotes = decoded.map((emote) => Emote7TV.fromJson(emote)); - return emotes - .map((emote) => Emote.from7TV(emote, EmoteType.sevenTVGlobal)) - .toList(); - } else { - return Future.error('Failed to get 7TV global emotes'); - } + return emotes + .map((emote) => Emote.from7TV(emote, EmoteType.sevenTVGlobal)) + .toList(); } /// Returns a tuple containing the emote set ID and a map of a channel's 7TV /// emotes to their URL. Future<(String, List)> getEmotesChannel({required String id}) async { - final url = Uri.parse('https://7tv.io/v3/users/twitch/$id'); + final data = await get('/users/twitch/$id'); - final response = await _client.get(url); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body); - final emoteSetId = decoded['emote_set']['id'] as String; - final emotes = (decoded['emote_set']['emotes'] as List) - .map((emote) => Emote7TV.fromJson(emote)); + final emoteSetId = data['emote_set']['id'] as String; + final emotes = (data['emote_set']['emotes'] as JsonList).map( + (emote) => Emote7TV.fromJson(emote), + ); - return ( - emoteSetId, - emotes - .map((emote) => Emote.from7TV(emote, EmoteType.sevenTVChannel)) - .where((emote) => emote.url.isNotEmpty) - .toList() - ); - } else { - return Future.error('Failed to get 7TV channel emotes'); - } + return ( + emoteSetId, + emotes + .map((emote) => Emote.from7TV(emote, EmoteType.sevenTVChannel)) + .where((emote) => emote.url.isNotEmpty) + .toList(), + ); } } diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index eea8be57..1028c216 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -1,180 +1,140 @@ -import 'dart:convert'; - +import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; +import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/models/badges.dart'; import 'package:frosty/models/category.dart'; import 'package:frosty/models/channel.dart'; import 'package:frosty/models/emotes.dart'; +import 'package:frosty/models/followed_channel.dart'; import 'package:frosty/models/shared_chat_session.dart'; import 'package:frosty/models/stream.dart'; import 'package:frosty/models/user.dart'; -import 'package:http/http.dart'; /// The Twitch service for making API calls. -class TwitchApi { - final Client _client; +class TwitchApi extends BaseApiClient { + static const String _helixBaseUrl = 'https://api.twitch.tv/helix'; + static const String _oauthBaseUrl = 'https://id.twitch.tv/oauth2'; + static const String _recentMessagesUrl = + 'https://recent-messages.robotty.de/api/v2'; - const TwitchApi(this._client); + TwitchApi(Dio dio) : super(dio, _helixBaseUrl); /// Returns a list of all Twitch global emotes. - Future> getEmotesGlobal({ - required Map headers, - }) async { - final url = Uri.parse('https://api.twitch.tv/helix/chat/emotes/global'); - final response = await _client.get(url, headers: headers); + Future> getEmotesGlobal() async { + final data = await get('/chat/emotes/global'); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body)['data'] as List; - final emotes = - decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList(); + final decoded = data['data'] as JsonList; + final emotes = decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList(); - return emotes - .map((emote) => Emote.fromTwitch(emote, EmoteType.twitchGlobal)) - .toList(); - } else { - return Future.error('Failed to get Twitch global emotes'); - } + return emotes + .map((emote) => Emote.fromTwitch(emote, EmoteType.twitchGlobal)) + .toList(); } /// Returns a list of a channel's Twitch emotes given their [id]. - Future> getEmotesChannel({ - required String id, - required Map headers, - }) async { - final url = - Uri.parse('https://api.twitch.tv/helix/chat/emotes?broadcaster_id=$id'); + Future> getEmotesChannel({required String id}) async { + final data = await get( + '/chat/emotes', + queryParameters: {'broadcaster_id': id}, + ); - final response = await _client.get(url, headers: headers); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body)['data'] as List; - final emotes = - decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList(); + final decoded = data['data'] as JsonList; + final emotes = decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList(); - return emotes.map((emote) { - switch (emote.emoteType) { - case 'bitstier': - return Emote.fromTwitch(emote, EmoteType.twitchBits); - case 'follower': - return Emote.fromTwitch(emote, EmoteType.twitchFollower); - case 'subscriptions': - return Emote.fromTwitch(emote, EmoteType.twitchChannel); - default: - return Emote.fromTwitch(emote, EmoteType.twitchChannel); - } - }).toList(); - } else { - return Future.error('Failed to get Twitch channel emotes'); - } + return emotes.map((emote) { + switch (emote.emoteType) { + case 'bitstier': + return Emote.fromTwitch(emote, EmoteType.twitchBits); + case 'follower': + return Emote.fromTwitch(emote, EmoteType.twitchFollower); + case 'subscriptions': + return Emote.fromTwitch(emote, EmoteType.twitchChannel); + default: + return Emote.fromTwitch(emote, EmoteType.twitchChannel); + } + }).toList(); } /// Returns a list of Twitch emotes under the provided [setId]. - Future> getEmotesSets({ - required String setId, - required Map headers, - }) async { - final url = Uri.parse( - 'https://api.twitch.tv/helix/chat/emotes/set?emote_set_id=$setId', + Future> getEmotesSets({required String setId}) async { + final data = await get( + '/chat/emotes/set', + queryParameters: {'emote_set_id': setId}, ); - final response = await _client.get(url, headers: headers); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body)['data'] as List; - final emotes = - decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList(); + final decoded = data['data'] as JsonList; + final emotes = decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList(); - return emotes.map((emote) { - switch (emote.emoteType) { - case 'globals': - case 'smilies': - return Emote.fromTwitch(emote, EmoteType.twitchGlobal); - case 'subscriptions': - return Emote.fromTwitch(emote, EmoteType.twitchSub); - default: - return Emote.fromTwitch(emote, EmoteType.twitchUnlocked); - } - }).toList(); - } else { - return Future.error('Failed to get Twitch emotes set'); - } + return emotes.map((emote) { + switch (emote.emoteType) { + case 'globals': + case 'smilies': + return Emote.fromTwitch(emote, EmoteType.twitchGlobal); + case 'subscriptions': + return Emote.fromTwitch(emote, EmoteType.twitchSub); + default: + return Emote.fromTwitch(emote, EmoteType.twitchUnlocked); + } + }).toList(); } /// Returns a map of global Twitch badges to their [Emote] object. - Future> getBadgesGlobal({ - required Map headers, - }) async { - final url = Uri.parse('https://api.twitch.tv/helix/chat/badges/global'); + Future> getBadgesGlobal() async { + final data = await get('/chat/badges/global'); - final response = await _client.get(url, headers: headers); - if (response.statusCode == 200) { - final result = {}; - final decoded = jsonDecode(response.body)['data'] as List; + final result = {}; + final decoded = data['data'] as JsonList; - for (final badge in decoded) { - final id = badge['set_id'] as String; - final versions = badge['versions'] as List; + for (final badge in decoded) { + final id = badge['set_id'] as String; + final versions = badge['versions'] as JsonList; - for (final version in versions) { - final badgeInfo = BadgeInfoTwitch.fromJson(version); - result['$id/${badgeInfo.id}'] = ChatBadge.fromTwitch(badgeInfo); - } + for (final version in versions) { + final badgeInfo = BadgeInfoTwitch.fromJson(version); + result['$id/${badgeInfo.id}'] = ChatBadge.fromTwitch(badgeInfo); } - - return result; - } else { - return Future.error('Failed to get Twitch global badges'); } + + return result; } /// Returns a map of a channel's Twitch badges to their [Emote] object. - Future> getBadgesChannel({ - required String id, - required Map headers, - }) async { - final url = - Uri.parse('https://api.twitch.tv/helix/chat/badges?broadcaster_id=$id'); + Future> getBadgesChannel({required String id}) async { + final data = await get( + '/chat/badges', + queryParameters: {'broadcaster_id': id}, + ); - final response = await _client.get(url, headers: headers); - if (response.statusCode == 200) { - final result = {}; - final decoded = jsonDecode(response.body)['data'] as List; + final result = {}; + final decoded = data['data'] as JsonList; - for (final badge in decoded) { - final id = badge['set_id'] as String; - final versions = badge['versions'] as List; + for (final badge in decoded) { + final id = badge['set_id'] as String; + final versions = badge['versions'] as JsonList; - for (final version in versions) { - final badgeInfo = BadgeInfoTwitch.fromJson(version); - result['$id/${badgeInfo.id}'] = ChatBadge.fromTwitch(badgeInfo); - } + for (final version in versions) { + final badgeInfo = BadgeInfoTwitch.fromJson(version); + result['$id/${badgeInfo.id}'] = ChatBadge.fromTwitch(badgeInfo); } - - return result; - } else { - return Future.error('Failed to get Twitch channel badges'); } + + return result; } - /// Returns the user's info given their token through [headers]. - Future getUserInfo({required Map headers}) async { - final url = Uri.parse('https://api.twitch.tv/helix/users'); + /// Returns the user's info given their token. + Future getUserInfo() async { + final data = await get('/users'); - final response = await _client.get(url, headers: headers); - if (response.statusCode == 200) { - final userData = jsonDecode(response.body)['data'] as List; - - return UserTwitch.fromJson(userData.first); - } else { - return Future.error('Failed to get Twitch user info'); - } + final userData = data['data'] as JsonList; + return UserTwitch.fromJson(userData.first); } /// Returns a token for an anonymous user. Future getDefaultToken() async { - final url = Uri( - scheme: 'https', - host: 'id.twitch.tv', - path: '/oauth2/token', + // Use custom base URL for OAuth + final data = await post( + '$_oauthBaseUrl/token', queryParameters: { 'client_id': clientId, 'client_secret': secret, @@ -182,403 +142,327 @@ class TwitchApi { }, ); - final response = await _client.post(url); - if (response.statusCode == 200) { - return jsonDecode(response.body)['access_token']; - } else { - return Future.error('Failed to get default token'); - } + return data['access_token'] as String; } /// Returns a bool indicating the validity of the given token. Future validateToken({required String token}) async { - final url = Uri.parse('https://id.twitch.tv/oauth2/validate'); - - final response = - await _client.get(url, headers: {'Authorization': 'Bearer $token'}); - if (response.statusCode == 200) { + try { + await get( + '$_oauthBaseUrl/validate', + headers: {'Authorization': 'Bearer $token'}, + ); return true; - } else { + } on UnauthorizedException { + // 401 -> token is invalid/expired (propagated from interceptor for validate requests) return false; + } on ApiException catch (e) { + // Network/timeout/server errors -> treat as indeterminate, not invalid + debugPrint('Token validation indeterminate: $e'); + rethrow; } } /// Returns a [StreamsTwitch] object that contains the top 20 streams and a cursor for further requests. - Future getTopStreams({ - required Map headers, - String? cursor, - }) async { - final url = Uri.parse( - cursor == null - ? 'https://api.twitch.tv/helix/streams' - : 'https://api.twitch.tv/helix/streams?after=$cursor', + Future getTopStreams({String? cursor}) async { + final data = await get( + '/streams', + queryParameters: cursor != null ? {'after': cursor} : null, ); - final response = await _client.get(url, headers: headers); - final decoded = jsonDecode(response.body); - if (response.statusCode == 200) { - return StreamsTwitch.fromJson(decoded); - } else { - return Future.error('Failed to get top streams: ${decoded['message']}'); - } + return StreamsTwitch.fromJson(data); } /// Returns a [StreamsTwitch] object that contains the given user ID's top 20 followed streams and a cursor for further requests. Future getFollowedStreams({ required String id, - required Map headers, String? cursor, }) async { - final url = Uri.parse( - cursor == null - ? 'https://api.twitch.tv/helix/streams/followed?user_id=$id' - : 'https://api.twitch.tv/helix/streams/followed?user_id=$id&after=$cursor', + final queryParams = {'user_id': id}; + if (cursor != null) queryParams['after'] = cursor; + + final data = await get( + '/streams/followed', + queryParameters: queryParams, ); - final response = await _client.get(url, headers: headers); - final decoded = jsonDecode(response.body); - if (response.statusCode == 200) { - return StreamsTwitch.fromJson(decoded); - } else { - return Future.error( - 'Failed to get followed streams: ${decoded['message']}', - ); - } + return StreamsTwitch.fromJson(data); + } + + /// Returns a [FollowedChannels] object containing all followed channels (including offline ones) for the given user ID. + Future getFollowedChannels({ + required String userId, + String? cursor, + }) async { + final queryParams = {'user_id': userId, 'first': '20'}; + if (cursor != null) queryParams['after'] = cursor; + + final data = await get( + '/channels/followed', + queryParameters: queryParams, + ); + + return FollowedChannels.fromJson(data); } /// Returns a [StreamsTwitch] object that contains the list of streams under the given game/category ID. Future getStreamsUnderCategory({ required String gameId, - required Map headers, String? cursor, }) async { - final url = Uri.parse( - cursor == null - ? 'https://api.twitch.tv/helix/streams?game_id=$gameId' - : 'https://api.twitch.tv/helix/streams?game_id=$gameId&after=$cursor', - ); + final queryParams = {'game_id': gameId}; + if (cursor != null) queryParams['after'] = cursor; - final response = await _client.get(url, headers: headers); - final decoded = jsonDecode(response.body); - if (response.statusCode == 200) { - return StreamsTwitch.fromJson(decoded); - } else { - return Future.error( - 'Failed to get streams under category: ${decoded['message']}', - ); - } + final data = await get('/streams', queryParameters: queryParams); + + return StreamsTwitch.fromJson(data); } /// Returns a [StreamTwitch] object containing the stream info associated with the given [userLogin]. - Future getStream({ - required String userLogin, - required Map headers, - }) async { - final uri = - Uri.parse('https://api.twitch.tv/helix/streams?user_login=$userLogin'); + Future getStream({required String userLogin}) async { + final data = await get( + '/streams', + queryParameters: {'user_login': userLogin}, + ); - final response = await _client.get(uri, headers: headers); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body); - final data = decoded['data'] as List; - - if (data.isNotEmpty) { - return StreamTwitch.fromJson(data.first); - } else { - return Future.error('$userLogin is offline'); - } + final streamData = data['data'] as JsonList; + if (streamData.isNotEmpty) { + return StreamTwitch.fromJson(streamData.first); } else { - return Future.error('Failed to get stream info'); + throw ApiException('$userLogin is offline', 404); } } - Future getStreamsByIds({ - required List userIds, - required Map headers, - }) async { - final uri = Uri.parse( - 'https://api.twitch.tv/helix/streams?${userIds.map((e) => 'user_id=$e').join('&')}&first=100', - ); + Future getStreamsByIds({required List userIds}) async { + // Create query string manually for multiple user_id parameters + final userIdParams = userIds.map((id) => 'user_id=$id').join('&'); + final url = '/streams?$userIdParams&first=100'; - final response = await _client.get(uri, headers: headers); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body); - return StreamsTwitch.fromJson(decoded); - } else { - return Future.error('Failed to get stream info'); - } + final data = await get(url); + + return StreamsTwitch.fromJson(data); } /// Returns a [UserTwitch] object containing the user info associated with the given [userLogin]. - Future getUser({ - String? userLogin, - String? id, - required Map headers, - }) async { - final url = Uri.parse( - id != null - ? 'https://api.twitch.tv/helix/users?id=$id' - : 'https://api.twitch.tv/helix/users?login=$userLogin', - ); + Future getUser({String? userLogin, String? id}) async { + final queryParams = {}; + if (id != null) { + queryParams['id'] = id; + } else if (userLogin != null) { + queryParams['login'] = userLogin; + } - final response = await _client.get(url, headers: headers); - final decoded = jsonDecode(response.body); + final data = await get('/users', queryParameters: queryParams); - if (response.statusCode == 200) { - final userData = decoded['data'] as List; - - if (userData.isNotEmpty) { - return UserTwitch.fromJson(userData.first); - } else { - return Future.error('User does not exist'); - } + final userData = data['data'] as JsonList; + if (userData.isNotEmpty) { + return UserTwitch.fromJson(userData.first); } else { - return Future.error('Failed to get user: ${decoded['message']}'); + throw NotFoundException('User does not exist'); } } /// Returns a [Channel] object containing a channels's info associated with the given [userId]. - Future getChannel({ - required String userId, - required Map headers, - }) async { - final url = Uri.parse( - 'https://api.twitch.tv/helix/channels?broadcaster_id=$userId', + Future getChannel({required String userId}) async { + final data = await get( + '/channels', + queryParameters: {'broadcaster_id': userId}, ); - final response = await _client.get(url, headers: headers); - final decoded = jsonDecode(response.body); - if (response.statusCode == 200) { - final channelData = decoded['data'] as List; - - if (channelData.isNotEmpty) { - return Channel.fromJson(channelData.first); - } else { - return Future.error('Channel does not exist'); - } + final channelData = data['data'] as JsonList; + if (channelData.isNotEmpty) { + return Channel.fromJson(channelData.first); } else { - return Future.error('Failed to get channel: ${decoded['message']}'); + throw ApiException('Channel does not exist', 404); } } /// Returns a list of [ChannelQuery] objects closest matching the given [query]. - Future> searchChannels({ - required String query, - required Map headers, - }) async { - final url = Uri.parse( - 'https://api.twitch.tv/helix/search/channels?first=8&query=$query', + Future> searchChannels({required String query}) async { + final data = await get( + '/search/channels', + queryParameters: {'first': '8', 'query': query}, ); - final response = await _client.get(url, headers: headers); - if (response.statusCode == 200) { - final channelData = jsonDecode(response.body)['data'] as List; - - return channelData.map((e) => ChannelQuery.fromJson(e)).toList(); - } else { - return Future.error('Failed to get channels'); - } + final channelData = data['data'] as JsonList; + return channelData.map((e) => ChannelQuery.fromJson(e)).toList(); } /// Returns a [CategoriesTwitch] object containing the next top 20 categories/games and a cursor for further requests. - Future getTopCategories({ - required Map headers, - String? cursor, - }) async { - final url = Uri.parse( - cursor == null - ? 'https://api.twitch.tv/helix/games/top' - : 'https://api.twitch.tv/helix/games/top?after=$cursor', + Future getTopCategories({String? cursor}) async { + final data = await get( + '/games/top', + queryParameters: cursor != null ? {'after': cursor} : null, ); - final response = await _client.get(url, headers: headers); - final decoded = jsonDecode(response.body); - if (response.statusCode == 200) { - return CategoriesTwitch.fromJson(decoded); - } else { - return Future.error( - 'Failed to get top categories: ${decoded['message']}', - ); - } + return CategoriesTwitch.fromJson(data); } /// Returns a [CategoriesTwitch] object containing the category info corresponding to the provided [gameId]. - Future getCategory({ - required Map headers, - required String gameId, - }) async { - final url = Uri.parse('https://api.twitch.tv/helix/games?id=$gameId'); + Future getCategory({required String gameId}) async { + final data = await get('/games', queryParameters: {'id': gameId}); - final response = await _client.get(url, headers: headers); - final decoded = jsonDecode(response.body); - if (response.statusCode == 200) { - return CategoriesTwitch.fromJson(decoded); - } else { - return Future.error('Failed to get category: ${decoded['message']}'); - } + return CategoriesTwitch.fromJson(data); } /// Returns a [CategoriesTwitch] containing up to 20 categories/games closest matching the [query] and a cursor for further requests. Future searchCategories({ - required Map headers, required String query, String? cursor, }) async { - final url = Uri.parse( - cursor == null - ? 'https://api.twitch.tv/helix/search/categories?first=8&query=$query' - : 'https://api.twitch.tv/helix/search/categories?first=8&query=$query&after=$cursor', + final queryParams = {'first': '8', 'query': query}; + if (cursor != null) queryParams['after'] = cursor; + + final data = await get( + '/search/categories', + queryParameters: queryParams, ); - final response = await _client.get(url, headers: headers); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body); - - return CategoriesTwitch.fromJson(decoded); - } else { - return Future.error('Failed to get categories'); - } + return CategoriesTwitch.fromJson(data); } /// Returns the sub count associated with the given [userId]. - Future getSubscriberCount({ - required String userId, - required Map headers, - }) async { - final uri = Uri.parse( - 'https://api.twitch.tv/helix/subscriptions?broadcaster_id=$userId', + Future getSubscriberCount({required String userId}) async { + final data = await get( + '/subscriptions', + queryParameters: {'broadcaster_id': userId}, ); - final response = await _client.get(uri, headers: headers); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body); - - return decoded['total'] as int; - } else { - return Future.error('Failed to get sub count'); - } + return data['total'] as int; } /// Returns a user's list of blocked users given their id. Future> getUserBlockedList({ required String id, - required Map headers, String? cursor, }) async { - final url = Uri.parse( - cursor == null - ? 'https://api.twitch.tv/helix/users/blocks?first=100&broadcaster_id=$id' - : 'https://api.twitch.tv/helix/users/blocks?first=100&broadcaster_id=$id&after=$cursor', + final queryParams = {'first': '100', 'broadcaster_id': id}; + if (cursor != null) queryParams['after'] = cursor; + + final data = await get( + '/users/blocks', + queryParameters: queryParams, ); - final response = await _client.get(url, headers: headers); - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body); - final cursor = decoded['pagination']['cursor']; - final blockedList = decoded['data'] as List; + final paginationCursor = data['pagination']['cursor']; + final blockedList = data['data'] as JsonList; - if (blockedList.isNotEmpty) { - final result = - blockedList.map((e) => UserBlockedTwitch.fromJson(e)).toList(); + if (blockedList.isNotEmpty) { + final result = blockedList + .map((e) => UserBlockedTwitch.fromJson(e)) + .toList(); - if (cursor != null) { - // Wait a bit (150 milliseconds) before recursively calling. - // This will prevent going over the rate limit to due a massive blocked users list. - // - // With the Twitch API, we can make up to 800 requests per minute. - // Waiting 150 milliseconds between requests will cap the rate here at 400 requests per minute. - await Future.delayed(const Duration(milliseconds: 150)); - result.addAll( - await getUserBlockedList( - id: id, - cursor: cursor, - headers: headers, - ), - ); - } - - return result; - } else { - debugPrint('User does not have anyone blocked'); - return []; + if (paginationCursor != null) { + // Wait a bit (150 milliseconds) before recursively calling. + // This will prevent going over the rate limit to due a massive blocked users list. + // + // With the Twitch API, we can make up to 800 requests per minute. + // Waiting 150 milliseconds between requests will cap the rate here at 400 requests per minute. + await Future.delayed(const Duration(milliseconds: 150)); + result.addAll( + await getUserBlockedList(id: id, cursor: paginationCursor), + ); } + + return result; } else { - return Future.error('User does not exist'); + debugPrint('User does not have anyone blocked'); + return []; } } // Blocks the user with the given ID and returns true on success or false on failure. - Future blockUser({ - required String userId, - required Map headers, - }) async { - final url = Uri.parse( - 'https://api.twitch.tv/helix/users/blocks?target_user_id=$userId', - ); - - final response = await _client.put(url, headers: headers); - if (response.statusCode == 204) { - return true; - } else { + Future blockUser({required String userId}) async { + try { + await put( + '/users/blocks', + queryParameters: {'target_user_id': userId}, + ); + return true; // If no exception, operation succeeded + } on ApiException { return false; } } // Unblocks the user with the given ID and returns true on success or false on failure. - Future unblockUser({ - required String userId, - required Map headers, - }) async { - final url = Uri.parse( - 'https://api.twitch.tv/helix/users/blocks?target_user_id=$userId', - ); - - final response = await _client.delete(url, headers: headers); - if (response.statusCode == 204) { - return true; - } else { + Future unblockUser({required String userId}) async { + try { + await delete( + '/users/blocks', + queryParameters: {'target_user_id': userId}, + ); + return true; // If no exception, operation succeeded + } on ApiException { return false; } } Future getSharedChatSession({ required String broadcasterId, - required Map headers, }) async { - final url = Uri.parse( - 'https://api.twitch.tv/helix/shared_chat/session?broadcaster_id=$broadcasterId', + final data = await get( + '/shared_chat/session', + queryParameters: {'broadcaster_id': broadcasterId}, ); - final response = await _client.get(url, headers: headers); - if (response.statusCode == 200) { - final sessionData = jsonDecode(response.body)['data'] as List; + final sessionData = data['data'] as JsonList; + if (sessionData.isEmpty) { + return null; + } - if (sessionData.isEmpty) { - return null; + return SharedChatSession.fromJson(sessionData.first); + } + + /// Gets the color used for the user's name in chat. + /// [userId] - The ID of the user whose chat color to get + /// Returns the color as a hex string or empty string if no color is set. + Future getUserChatColor({required String userId}) async { + try { + final data = await get( + '/chat/color', + queryParameters: {'user_id': userId}, + ); + + final users = data['data'] as JsonList; + if (users.isNotEmpty) { + final user = users.first as JsonMap; + return user['color'] as String? ?? ''; } - return SharedChatSession.fromJson(sessionData.first); - } else { - return Future.error('Failed to get shared chat session info'); + return ''; + } on ApiException { + return ''; } } - // Unblocks the user with the given ID and returns true on success or false on failure. - Future> getRecentMessages({ - required String userLogin, + /// Updates the color used for the user's name in chat. + /// [userId] - The ID of the user whose chat color to update + /// [color] - The color to use. Can be a named color (blue, blue_violet, etc.) or hex code for Turbo/Prime users + /// Returns true on success or false on failure. + Future updateUserChatColor({ + required String userId, + required String color, }) async { - final url = Uri.parse( - 'https://recent-messages.robotty.de/api/v2/recent-messages/$userLogin', + try { + await put( + '/chat/color', + queryParameters: {'user_id': userId, 'color': color}, + ); + return true; // If no exception, operation succeeded + } on ApiException catch (e) { + // Log the specific error for debugging + debugPrint('Failed to update chat color: $e'); + return false; + } + } + + // Gets recent messages from a third-party service. + Future getRecentMessages({required String userLogin}) async { + // Use custom base URL for third-party service + final data = await get( + '$_recentMessagesUrl/recent-messages/$userLogin', ); - final response = await _client.get(url); - - final decodedBody = utf8.decode(response.bodyBytes); - - if (response.statusCode == 200) { - return jsonDecode(decodedBody)['messages'] as List; - } else { - return Future.error('Failed to get recent messages for $userLogin'); - } + return data['messages'] as JsonList; } } diff --git a/lib/apis/twitch_auth_interceptor.dart b/lib/apis/twitch_auth_interceptor.dart new file mode 100644 index 00000000..b5cd0a97 --- /dev/null +++ b/lib/apis/twitch_auth_interceptor.dart @@ -0,0 +1,31 @@ +import 'package:dio/dio.dart'; +import 'package:frosty/screens/settings/stores/auth_store.dart'; + +/// Dio interceptor that automatically adds Twitch authentication headers +/// to requests targeting Twitch API endpoints. +class TwitchAuthInterceptor extends Interceptor { + final AuthStore _authStore; + + const TwitchAuthInterceptor(this._authStore); + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + // Check if this is a Twitch API request that needs authentication + if (_shouldAddTwitchHeaders(options.uri)) { + // Add Twitch auth headers automatically + final twitchHeaders = _authStore.headersTwitch; + options.headers.addAll(twitchHeaders); + } + + handler.next(options); + } + + /// Determines if the request URL is for a Twitch API endpoint that requires authentication + bool _shouldAddTwitchHeaders(Uri uri) { + final url = uri.toString(); + + // Add headers for Twitch Helix API and OAuth endpoints + return url.startsWith('https://api.twitch.tv/helix') || + url.startsWith('https://id.twitch.tv/oauth2'); + } +} diff --git a/lib/apis/unauthorized_interceptor.dart b/lib/apis/unauthorized_interceptor.dart new file mode 100644 index 00000000..2dad5461 --- /dev/null +++ b/lib/apis/unauthorized_interceptor.dart @@ -0,0 +1,91 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:frosty/main.dart'; +import 'package:frosty/screens/onboarding/login_webview.dart'; +import 'package:frosty/screens/settings/stores/auth_store.dart'; +import 'package:frosty/widgets/frosty_dialog.dart'; +import 'package:provider/provider.dart'; + +/// Dio interceptor that catches 401 Unauthorized errors and shows a login dialog +class UnauthorizedInterceptor extends Interceptor { + final AuthStore _authStore; + + UnauthorizedInterceptor(this._authStore); + + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + // Check if this is a 401 Unauthorized error + if (err.response?.statusCode == 401) { + // For token validation requests, let the error propagate so validateToken can handle it + if (err.requestOptions.uri.path.endsWith('/validate')) { + handler.next(err); + return; + } + + // For other requests, show login dialog and don't propagate + _showLoginDialog(); + return; + } + + // For non-401 errors, continue with normal error handling + handler.next(err); + } + + void _showLoginDialog() { + // Use the global navigator key to show dialog without needing BuildContext + final context = navigatorKey.currentContext; + if (context == null) return; + + // Use a flag to prevent multiple dialogs + if (_isDialogShowing) return; + _isDialogShowing = true; + + // Determine if user is logged in but missing scopes vs completely logged out + final isLoggedIn = _authStore.isLoggedIn; + final title = isLoggedIn ? 'Missing permissions' : 'Session expired'; + final message = isLoggedIn + ? 'Your session is missing permissions. Please log in again to continue.' + : 'Your session has expired. Please log in again to continue.'; + + showDialog( + context: context, + barrierDismissible: false, // User must choose an action + builder: (BuildContext dialogContext) { + return FrostyDialog( + title: title, + message: message, + actions: [ + TextButton( + onPressed: () { + _isDialogShowing = false; + Navigator.of(dialogContext).pop(); // Close dialog + }, + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + _isDialogShowing = false; + Navigator.of(dialogContext).pop(); // Close dialog + // Navigate to login WebView + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => Provider.value( + value: _authStore, + child: const LoginWebView(), + ), + ), + ); + }, + child: Text('Log in'), + ), + ], + ); + }, + ).then((_) { + // Reset flag when dialog is dismissed + _isDialogShowing = false; + }); + } + + static bool _isDialogShowing = false; +} diff --git a/lib/constants.dart b/lib/constants.dart index f9c18f49..3fcfcd3a 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + /// Twitch API client ID. const clientId = String.fromEnvironment('CLIENT_ID'); @@ -19,6 +21,9 @@ const zeroWidthEmotes = [ /// Regex for matching strings that contain lower or upper case English characters. final regexEnglish = RegExp(r'[a-zA-Z]'); +/// Regex for matching strings that contain only numeric characters. +final regexNumbersOnly = RegExp(r'^\d+$'); + /// Regex for matching URLs and file names in text. final regexLink = RegExp( r'(? chatColorValues = { + 'blue': Color(0xFF0000FF), + 'blue_violet': Color(0xFF8A2BE2), + 'cadet_blue': Color(0xFF5F9EA0), + 'chocolate': Color(0xFFD2691E), + 'coral': Color(0xFFFF7F50), + 'dodger_blue': Color(0xFF1E90FF), + 'firebrick': Color(0xFFB22222), + 'golden_rod': Color(0xFFDAA520), + 'green': Color(0xFF008000), + 'hot_pink': Color(0xFFFF69B4), + 'orange_red': Color(0xFFFF4500), + 'red': Color(0xFFFF0000), + 'sea_green': Color(0xFF2E8B57), + 'spring_green': Color(0xFF00FF7F), + 'yellow_green': Color(0xFF9ACD32), +}; diff --git a/lib/main.dart b/lib/main.dart index 5489d29d..59ad27ed 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,9 +10,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:frosty/apis/bttv_api.dart'; +import 'package:frosty/apis/dio_client.dart'; import 'package:frosty/apis/ffz_api.dart'; import 'package:frosty/apis/seventv_api.dart'; import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/apis/twitch_auth_interceptor.dart'; +import 'package:frosty/apis/unauthorized_interceptor.dart'; import 'package:frosty/cache_manager.dart'; import 'package:frosty/firebase_options.dart'; import 'package:frosty/screens/channel/channel.dart'; @@ -20,10 +23,10 @@ import 'package:frosty/screens/home/home.dart'; import 'package:frosty/screens/onboarding/onboarding_intro.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; +import 'package:frosty/stores/global_assets_store.dart'; import 'package:frosty/theme.dart'; import 'package:frosty/utils.dart'; import 'package:frosty/widgets/alert_message.dart'; -import 'package:http/http.dart'; import 'package:mobx/mobx.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -34,9 +37,7 @@ void main() async { CustomCacheManager.removeOrphanedCacheFiles(); - await Firebase.initializeApp( - options: DefaultFirebaseOptions.currentPlatform, - ); + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); // Pass all uncaught "fatal" errors from the framework to Crashlytics FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError; @@ -72,27 +73,45 @@ void main() async { // Create a MobX reaction that will save the settings on disk every time they are changed. autorun((_) => prefs.setString('settings', jsonEncode(settingsStore))); - /// Initialize API services with a common client. + /// Initialize API services with a common Dio client. /// This will prevent every request from creating a new client instance. - final client = Client(); - final twitchApiService = TwitchApi(client); - final bttvApiService = BTTVApi(client); - final ffzApiService = FFZApi(client); - final sevenTVApiService = SevenTVApi(client); + final dioClient = DioClient.createClient(); + + // Create API services + final twitchApiService = TwitchApi(dioClient); + final bttvApiService = BTTVApi(dioClient); + final ffzApiService = FFZApi(dioClient); + final sevenTVApiService = SevenTVApi(dioClient); + + // Create global assets store (shared cache for global emotes/badges) + final globalAssetsStore = GlobalAssetsStore( + twitchApi: twitchApiService, + bttvApi: bttvApiService, + ffzApi: ffzApiService, + sevenTVApi: sevenTVApiService, + ); // Create and initialize the authentication store final authStore = AuthStore(twitchApi: twitchApiService); + + // Add the auth interceptor to the Dio client after AuthStore creation + dioClient.interceptors.add(TwitchAuthInterceptor(authStore)); + + // Add the unauthorized interceptor to catch 401 errors + dioClient.interceptors.add(UnauthorizedInterceptor(authStore)); + await authStore.init(); runApp( MultiProvider( providers: [ - Provider(create: (_) => authStore), - Provider(create: (_) => settingsStore), - Provider(create: (_) => twitchApiService), - Provider(create: (_) => bttvApiService), - Provider(create: (_) => ffzApiService), - Provider(create: (_) => sevenTVApiService), + Provider.value(value: authStore), + Provider.value(value: settingsStore), + Provider.value(value: twitchApiService), + Provider.value(value: bttvApiService), + Provider.value(value: ffzApiService), + Provider.value(value: sevenTVApiService), + Provider.value(value: globalAssetsStore), ], child: MyApp(firstRun: firstRun), ), @@ -105,10 +124,7 @@ final navigatorKey = GlobalKey(); class MyApp extends StatefulWidget { final bool firstRun; - const MyApp({ - super.key, - this.firstRun = false, - }); + const MyApp({super.key, this.firstRun = false}); @override State createState() => _MyAppState(); @@ -137,8 +153,9 @@ class _MyAppState extends State { return Observer( builder: (context) { final settingsStore = context.read(); - final themes = - FrostyThemes(colorSchemeSeed: Color(settingsStore.accentColor)); + final themes = FrostyThemes( + colorSchemeSeed: Color(settingsStore.accentColor), + ); return Provider( create: (_) => themes, @@ -149,8 +166,8 @@ class _MyAppState extends State { themeMode: settingsStore.themeType == ThemeType.system ? ThemeMode.system : settingsStore.themeType == ThemeType.light - ? ThemeMode.light - : ThemeMode.dark, + ? ThemeMode.light + : ThemeMode.dark, home: widget.firstRun ? const OnboardingIntro() : const Home(), navigatorKey: navigatorKey, ), @@ -198,12 +215,8 @@ class _MyAppState extends State { try { final twitchApi = context.read(); - final authStore = context.read(); - final user = await twitchApi.getUser( - userLogin: channelName, - headers: authStore.headersTwitch, - ); + final user = await twitchApi.getUser(userLogin: channelName); final route = MaterialPageRoute( builder: (context) => VideoChat( @@ -223,15 +236,18 @@ class _MyAppState extends State { debugPrint('Failed to open link $uri due to error: $e'); if (navigatorKey.currentContext == null) return; - ScaffoldMessenger.of(navigatorKey.currentContext!) - .showSnackBar(failureSnackbar); + ScaffoldMessenger.of( + navigatorKey.currentContext!, + ).showSnackBar(failureSnackbar); } } // TODO: Here we can implement handlers for other types of links else { // If we get here, it's a link format that we're unable to handle if (navigatorKey.currentContext == null) return; - ScaffoldMessenger.of(navigatorKey.currentContext!).showSnackBar(failureSnackbar); + ScaffoldMessenger.of( + navigatorKey.currentContext!, + ).showSnackBar(failureSnackbar); } } diff --git a/lib/models/badges.dart b/lib/models/badges.dart index 44e51e84..7936f8b3 100644 --- a/lib/models/badges.dart +++ b/lib/models/badges.dart @@ -35,12 +35,7 @@ class BadgeInfoFFZ { final String color; final BadgeUrlsFFZ urls; - const BadgeInfoFFZ( - this.id, - this.title, - this.color, - this.urls, - ); + const BadgeInfoFFZ(this.id, this.title, this.color, this.urls); factory BadgeInfoFFZ.fromJson(Map json) => _$BadgeInfoFFZFromJson(json); @@ -55,11 +50,7 @@ class BadgeUrlsFFZ { @JsonKey(name: '4') final String url4x; - const BadgeUrlsFFZ( - this.url1x, - this.url2x, - this.url4x, - ); + const BadgeUrlsFFZ(this.url1x, this.url2x, this.url4x); factory BadgeUrlsFFZ.fromJson(Map json) => _$BadgeUrlsFFZFromJson(json); @@ -71,11 +62,7 @@ class BadgeInfo7TV { final List> urls; final List users; - BadgeInfo7TV( - this.tooltip, - this.urls, - this.users, - ); + BadgeInfo7TV(this.tooltip, this.urls, this.users); factory BadgeInfo7TV.fromJson(Map json) => _$BadgeInfo7TVFromJson(json); @@ -86,10 +73,7 @@ class BadgeInfoBTTV { final String providerId; final BadgeDetailsBTTV badge; - BadgeInfoBTTV( - this.providerId, - this.badge, - ); + BadgeInfoBTTV(this.providerId, this.badge); factory BadgeInfoBTTV.fromJson(Map json) => _$BadgeInfoBTTVFromJson(json); @@ -100,10 +84,7 @@ class BadgeDetailsBTTV { final String description; final String svg; - BadgeDetailsBTTV( - this.description, - this.svg, - ); + BadgeDetailsBTTV(this.description, this.svg); factory BadgeDetailsBTTV.fromJson(Map json) => _$BadgeDetailsBTTVFromJson(json); @@ -123,29 +104,29 @@ class ChatBadge { }); factory ChatBadge.fromTwitch(BadgeInfoTwitch badge) => ChatBadge( - name: badge.title, - url: badge.imageUrl4x, - type: BadgeType.twitch, - ); + name: badge.title, + url: badge.imageUrl4x, + type: BadgeType.twitch, + ); factory ChatBadge.fromBTTV(BadgeInfoBTTV badge) => ChatBadge( - name: badge.badge.description, - url: badge.badge.svg, - type: BadgeType.bttv, - ); + name: badge.badge.description, + url: badge.badge.svg, + type: BadgeType.bttv, + ); factory ChatBadge.fromFFZ(BadgeInfoFFZ badge) => ChatBadge( - name: badge.title, - url: badge.urls.url4x, - color: badge.color, - type: BadgeType.ffz, - ); + name: badge.title, + url: badge.urls.url4x, + color: badge.color, + type: BadgeType.ffz, + ); factory ChatBadge.from7TV(BadgeInfo7TV badge) => ChatBadge( - name: badge.tooltip, - url: badge.urls[2][1], - type: BadgeType.sevenTV, - ); + name: badge.tooltip, + url: badge.urls[2][1], + type: BadgeType.sevenTV, + ); } enum BadgeType { diff --git a/lib/models/badges.g.dart b/lib/models/badges.g.dart index dd43b922..5d5d1b79 100644 --- a/lib/models/badges.g.dart +++ b/lib/models/badges.g.dart @@ -17,25 +17,22 @@ BadgeInfoTwitch _$BadgeInfoTwitchFromJson(Map json) => ); BadgeInfoFFZ _$BadgeInfoFFZFromJson(Map json) => BadgeInfoFFZ( - (json['id'] as num).toInt(), - json['title'] as String, - json['color'] as String, - BadgeUrlsFFZ.fromJson(json['urls'] as Map), - ); + (json['id'] as num).toInt(), + json['title'] as String, + json['color'] as String, + BadgeUrlsFFZ.fromJson(json['urls'] as Map), +); -BadgeUrlsFFZ _$BadgeUrlsFFZFromJson(Map json) => BadgeUrlsFFZ( - json['1'] as String, - json['2'] as String, - json['4'] as String, - ); +BadgeUrlsFFZ _$BadgeUrlsFFZFromJson(Map json) => + BadgeUrlsFFZ(json['1'] as String, json['2'] as String, json['4'] as String); BadgeInfo7TV _$BadgeInfo7TVFromJson(Map json) => BadgeInfo7TV( - json['tooltip'] as String, - (json['urls'] as List) - .map((e) => (e as List).map((e) => e as String).toList()) - .toList(), - (json['users'] as List).map((e) => e as String).toList(), - ); + json['tooltip'] as String, + (json['urls'] as List) + .map((e) => (e as List).map((e) => e as String).toList()) + .toList(), + (json['users'] as List).map((e) => e as String).toList(), +); BadgeInfoBTTV _$BadgeInfoBTTVFromJson(Map json) => BadgeInfoBTTV( @@ -44,7 +41,4 @@ BadgeInfoBTTV _$BadgeInfoBTTVFromJson(Map json) => ); BadgeDetailsBTTV _$BadgeDetailsBTTVFromJson(Map json) => - BadgeDetailsBTTV( - json['description'] as String, - json['svg'] as String, - ); + BadgeDetailsBTTV(json['description'] as String, json['svg'] as String); diff --git a/lib/models/category.dart b/lib/models/category.dart index 05dc473a..4132d2c0 100644 --- a/lib/models/category.dart +++ b/lib/models/category.dart @@ -8,11 +8,7 @@ class CategoryTwitch { final String id; final String name; - const CategoryTwitch( - this.boxArtUrl, - this.id, - this.name, - ); + const CategoryTwitch(this.boxArtUrl, this.id, this.name); factory CategoryTwitch.fromJson(Map json) => _$CategoryTwitchFromJson(json); @@ -23,10 +19,7 @@ class CategoriesTwitch { final List data; final Map? pagination; - const CategoriesTwitch( - this.data, - this.pagination, - ); + const CategoriesTwitch(this.data, this.pagination); factory CategoriesTwitch.fromJson(Map json) => _$CategoriesTwitchFromJson(json); diff --git a/lib/models/channel.dart b/lib/models/channel.dart index be13fa41..fae8512b 100644 --- a/lib/models/channel.dart +++ b/lib/models/channel.dart @@ -8,11 +8,19 @@ class Channel { final String broadcasterId; final String broadcasterLogin; final String broadcasterName; + final String broadcasterLanguage; + final String title; + final String gameId; + final String gameName; const Channel({ required this.broadcasterId, required this.broadcasterLogin, required this.broadcasterName, + required this.broadcasterLanguage, + required this.title, + required this.gameId, + required this.gameName, }); factory Channel.fromJson(Map json) => diff --git a/lib/models/channel.g.dart b/lib/models/channel.g.dart index fa31252f..bbb41d04 100644 --- a/lib/models/channel.g.dart +++ b/lib/models/channel.g.dart @@ -7,15 +7,19 @@ part of 'channel.dart'; // ************************************************************************** Channel _$ChannelFromJson(Map json) => Channel( - broadcasterId: json['broadcaster_id'] as String, - broadcasterLogin: json['broadcaster_login'] as String, - broadcasterName: json['broadcaster_name'] as String, - ); + broadcasterId: json['broadcaster_id'] as String, + broadcasterLogin: json['broadcaster_login'] as String, + broadcasterName: json['broadcaster_name'] as String, + broadcasterLanguage: json['broadcaster_language'] as String, + title: json['title'] as String, + gameId: json['game_id'] as String, + gameName: json['game_name'] as String, +); ChannelQuery _$ChannelQueryFromJson(Map json) => ChannelQuery( - broadcasterLogin: json['broadcaster_login'] as String, - displayName: json['display_name'] as String, - id: json['id'] as String, - isLive: json['is_live'] as bool, - startedAt: json['started_at'] as String, - ); + broadcasterLogin: json['broadcaster_login'] as String, + displayName: json['display_name'] as String, + id: json['id'] as String, + isLive: json['is_live'] as bool, + startedAt: json['started_at'] as String, +); diff --git a/lib/models/chatters.dart b/lib/models/chatters.dart index c059807e..8129e30e 100644 --- a/lib/models/chatters.dart +++ b/lib/models/chatters.dart @@ -7,10 +7,7 @@ class ChatUsers { final int chatterCount; final Chatters chatters; - ChatUsers( - this.chatterCount, - this.chatters, - ); + ChatUsers(this.chatterCount, this.chatters); factory ChatUsers.fromJson(Map json) => _$ChatUsersFromJson(json); diff --git a/lib/models/chatters.g.dart b/lib/models/chatters.g.dart index 9f87a966..13349ccd 100644 --- a/lib/models/chatters.g.dart +++ b/lib/models/chatters.g.dart @@ -7,16 +7,16 @@ part of 'chatters.dart'; // ************************************************************************** ChatUsers _$ChatUsersFromJson(Map json) => ChatUsers( - (json['chatter_count'] as num).toInt(), - Chatters.fromJson(json['chatters'] as Map), - ); + (json['chatter_count'] as num).toInt(), + Chatters.fromJson(json['chatters'] as Map), +); Chatters _$ChattersFromJson(Map json) => Chatters( - (json['broadcaster'] as List).map((e) => e as String).toList(), - (json['vips'] as List).map((e) => e as String).toList(), - (json['moderators'] as List).map((e) => e as String).toList(), - (json['staff'] as List).map((e) => e as String).toList(), - (json['admins'] as List).map((e) => e as String).toList(), - (json['global_mods'] as List).map((e) => e as String).toList(), - (json['viewers'] as List).map((e) => e as String).toList(), - ); + (json['broadcaster'] as List).map((e) => e as String).toList(), + (json['vips'] as List).map((e) => e as String).toList(), + (json['moderators'] as List).map((e) => e as String).toList(), + (json['staff'] as List).map((e) => e as String).toList(), + (json['admins'] as List).map((e) => e as String).toList(), + (json['global_mods'] as List).map((e) => e as String).toList(), + (json['viewers'] as List).map((e) => e as String).toList(), +); diff --git a/lib/models/emotes.dart b/lib/models/emotes.dart index 47954092..b4b07599 100644 --- a/lib/models/emotes.dart +++ b/lib/models/emotes.dart @@ -12,12 +12,7 @@ class EmoteTwitch { final String? emoteType; final String? ownerId; - const EmoteTwitch( - this.id, - this.name, - this.emoteType, - this.ownerId, - ); + const EmoteTwitch(this.id, this.name, this.emoteType, this.ownerId); factory EmoteTwitch.fromJson(Map json) => _$EmoteTwitchFromJson(json); @@ -29,10 +24,7 @@ class EmoteBTTV { final String id; final String code; - const EmoteBTTV( - this.id, - this.code, - ); + const EmoteBTTV(this.id, this.code); factory EmoteBTTV.fromJson(Map json) => _$EmoteBTTVFromJson(json); @@ -43,10 +35,7 @@ class EmoteBTTVChannel { final List channelEmotes; final List sharedEmotes; - const EmoteBTTVChannel( - this.channelEmotes, - this.sharedEmotes, - ); + const EmoteBTTVChannel(this.channelEmotes, this.sharedEmotes); factory EmoteBTTVChannel.fromJson(Map json) => _$EmoteBTTVChannelFromJson(json); @@ -59,11 +48,7 @@ class RoomFFZ { final ImagesFFZ? vipBadge; final ImagesFFZ? modUrls; - const RoomFFZ( - this.set, - this.vipBadge, - this.modUrls, - ); + const RoomFFZ(this.set, this.vipBadge, this.modUrls); factory RoomFFZ.fromJson(Map json) => _$RoomFFZFromJson(json); @@ -78,11 +63,7 @@ class ImagesFFZ { @JsonKey(name: '4') final String? url4x; - const ImagesFFZ( - this.url1x, - this.url2x, - this.url4x, - ); + const ImagesFFZ(this.url1x, this.url2x, this.url4x); factory ImagesFFZ.fromJson(Map json) => _$ImagesFFZFromJson(json); @@ -93,10 +74,7 @@ class OwnerFFZ { final String displayName; final String name; - const OwnerFFZ({ - required this.displayName, - required this.name, - }); + const OwnerFFZ({required this.displayName, required this.name}); factory OwnerFFZ.fromJson(Map json) => _$OwnerFFZFromJson(json); @@ -130,11 +108,7 @@ class Emote7TV { final String name; final Emote7TVData data; - const Emote7TV( - this.id, - this.name, - this.data, - ); + const Emote7TV(this.id, this.name, this.data); factory Emote7TV.fromJson(Map json) => _$Emote7TVFromJson(json); @@ -145,10 +119,7 @@ class Owner7TV { final String username; final String displayName; - const Owner7TV({ - required this.username, - required this.displayName, - }); + const Owner7TV({required this.username, required this.displayName}); factory Owner7TV.fromJson(Map json) => _$Owner7TVFromJson(json); @@ -162,13 +133,7 @@ class Emote7TVData { final Owner7TV? owner; final Emote7TVHost host; - const Emote7TVData( - this.id, - this.name, - this.flags, - this.owner, - this.host, - ); + const Emote7TVData(this.id, this.name, this.flags, this.owner, this.host); factory Emote7TVData.fromJson(Map json) => _$Emote7TVDataFromJson(json); @@ -179,10 +144,7 @@ class Emote7TVHost { final String url; final List files; - Emote7TVHost( - this.url, - this.files, - ); + Emote7TVHost(this.url, this.files); factory Emote7TVHost.fromJson(Map json) => _$Emote7TVHostFromJson(json); @@ -195,12 +157,7 @@ class Emote7TVFile { final int height; final String format; - Emote7TVFile( - this.name, - this.width, - this.height, - this.format, - ); + Emote7TVFile(this.name, this.width, this.height, this.format); factory Emote7TVFile.fromJson(Map json) => _$Emote7TVFileFromJson(json); @@ -234,36 +191,37 @@ class Emote { }); factory Emote.fromTwitch(EmoteTwitch emote, EmoteType type) => Emote( - name: emote.name, - zeroWidth: false, - url: - 'https://static-cdn.jtvnw.net/emoticons/v2/${emote.id}/default/dark/3.0', - type: type, - ownerId: emote.ownerId, - ); + name: emote.name, + zeroWidth: false, + url: + 'https://static-cdn.jtvnw.net/emoticons/v2/${emote.id}/default/dark/3.0', + type: type, + ownerId: emote.ownerId, + ); factory Emote.fromBTTV(EmoteBTTV emote, EmoteType type) => Emote( - name: emote.code, - zeroWidth: zeroWidthEmotes.contains(emote.code), - url: 'https://cdn.betterttv.net/emote/${emote.id}/3x', - type: type, - ); + name: emote.code, + zeroWidth: zeroWidthEmotes.contains(emote.code), + url: 'https://cdn.betterttv.net/emote/${emote.id}/3x', + type: type, + ); factory Emote.fromFFZ(EmoteFFZ emote, EmoteType type) => Emote( - name: emote.name, - zeroWidth: false, - width: emote.width, - height: emote.height, - url: emote.animated?.url4x ?? - emote.animated?.url2x ?? - emote.animated?.url1x ?? - emote.urls.url4x ?? - emote.urls.url2x ?? - emote.urls.url1x, - type: type, - ownerDisplayName: emote.owner.displayName, - ownerUsername: emote.owner.name, - ); + name: emote.name, + zeroWidth: false, + width: emote.width, + height: emote.height, + url: + emote.animated?.url4x ?? + emote.animated?.url2x ?? + emote.animated?.url1x ?? + emote.urls.url4x ?? + emote.urls.url2x ?? + emote.urls.url1x, + type: type, + ownerDisplayName: emote.owner.displayName, + ownerUsername: emote.owner.name, + ); factory Emote.from7TV(Emote7TV emote, EmoteType type) { final emoteData = emote.data; diff --git a/lib/models/emotes.g.dart b/lib/models/emotes.g.dart index 15d2cb72..6ffb0c2a 100644 --- a/lib/models/emotes.g.dart +++ b/lib/models/emotes.g.dart @@ -7,16 +7,14 @@ part of 'emotes.dart'; // ************************************************************************** EmoteTwitch _$EmoteTwitchFromJson(Map json) => EmoteTwitch( - json['id'] as String, - json['name'] as String, - json['emote_type'] as String?, - json['owner_id'] as String?, - ); + json['id'] as String, + json['name'] as String, + json['emote_type'] as String?, + json['owner_id'] as String?, +); -EmoteBTTV _$EmoteBTTVFromJson(Map json) => EmoteBTTV( - json['id'] as String, - json['code'] as String, - ); +EmoteBTTV _$EmoteBTTVFromJson(Map json) => + EmoteBTTV(json['id'] as String, json['code'] as String); EmoteBTTVChannel _$EmoteBTTVChannelFromJson(Map json) => EmoteBTTVChannel( @@ -29,97 +27,94 @@ EmoteBTTVChannel _$EmoteBTTVChannelFromJson(Map json) => ); RoomFFZ _$RoomFFZFromJson(Map json) => RoomFFZ( - (json['set'] as num).toInt(), - json['vip_badge'] == null - ? null - : ImagesFFZ.fromJson(json['vip_badge'] as Map), - json['mod_urls'] == null - ? null - : ImagesFFZ.fromJson(json['mod_urls'] as Map), - ); + (json['set'] as num).toInt(), + json['vip_badge'] == null + ? null + : ImagesFFZ.fromJson(json['vip_badge'] as Map), + json['mod_urls'] == null + ? null + : ImagesFFZ.fromJson(json['mod_urls'] as Map), +); -ImagesFFZ _$ImagesFFZFromJson(Map json) => ImagesFFZ( - json['1'] as String, - json['2'] as String?, - json['4'] as String?, - ); +ImagesFFZ _$ImagesFFZFromJson(Map json) => + ImagesFFZ(json['1'] as String, json['2'] as String?, json['4'] as String?); OwnerFFZ _$OwnerFFZFromJson(Map json) => OwnerFFZ( - displayName: json['display_name'] as String, - name: json['name'] as String, - ); + displayName: json['display_name'] as String, + name: json['name'] as String, +); EmoteFFZ _$EmoteFFZFromJson(Map json) => EmoteFFZ( - json['name'] as String, - (json['height'] as num).toInt(), - (json['width'] as num).toInt(), - OwnerFFZ.fromJson(json['owner'] as Map), - ImagesFFZ.fromJson(json['urls'] as Map), - json['animated'] == null - ? null - : ImagesFFZ.fromJson(json['animated'] as Map), - ); + json['name'] as String, + (json['height'] as num).toInt(), + (json['width'] as num).toInt(), + OwnerFFZ.fromJson(json['owner'] as Map), + ImagesFFZ.fromJson(json['urls'] as Map), + json['animated'] == null + ? null + : ImagesFFZ.fromJson(json['animated'] as Map), +); Emote7TV _$Emote7TVFromJson(Map json) => Emote7TV( - json['id'] as String, - json['name'] as String, - Emote7TVData.fromJson(json['data'] as Map), - ); + json['id'] as String, + json['name'] as String, + Emote7TVData.fromJson(json['data'] as Map), +); Owner7TV _$Owner7TVFromJson(Map json) => Owner7TV( - username: json['username'] as String, - displayName: json['display_name'] as String, - ); + username: json['username'] as String, + displayName: json['display_name'] as String, +); Emote7TVData _$Emote7TVDataFromJson(Map json) => Emote7TVData( - json['id'] as String, - json['name'] as String, - (json['flags'] as num).toInt(), - json['owner'] == null - ? null - : Owner7TV.fromJson(json['owner'] as Map), - Emote7TVHost.fromJson(json['host'] as Map), - ); + json['id'] as String, + json['name'] as String, + (json['flags'] as num).toInt(), + json['owner'] == null + ? null + : Owner7TV.fromJson(json['owner'] as Map), + Emote7TVHost.fromJson(json['host'] as Map), +); Emote7TVHost _$Emote7TVHostFromJson(Map json) => Emote7TVHost( - json['url'] as String, - (json['files'] as List) - .map((e) => Emote7TVFile.fromJson(e as Map)) - .toList(), - ); + json['url'] as String, + (json['files'] as List) + .map((e) => Emote7TVFile.fromJson(e as Map)) + .toList(), +); Emote7TVFile _$Emote7TVFileFromJson(Map json) => Emote7TVFile( - json['name'] as String, - (json['width'] as num).toInt(), - (json['height'] as num).toInt(), - json['format'] as String, - ); + json['name'] as String, + (json['width'] as num).toInt(), + (json['height'] as num).toInt(), + json['format'] as String, +); Emote _$EmoteFromJson(Map json) => Emote( - name: json['name'] as String, - realName: json['realName'] as String?, - width: (json['width'] as num?)?.toInt(), - height: (json['height'] as num?)?.toInt(), - zeroWidth: json['zeroWidth'] as bool, - url: json['url'] as String, - type: $enumDecode(_$EmoteTypeEnumMap, json['type']), - ownerDisplayName: json['ownerDisplayName'] as String?, - ownerUsername: json['ownerUsername'] as String?, - ownerId: json['ownerId'] as String?, - ); + name: json['name'] as String, + realName: json['realName'] as String?, + width: (json['width'] as num?)?.toInt(), + height: (json['height'] as num?)?.toInt(), + zeroWidth: json['zeroWidth'] as bool, + url: json['url'] as String, + type: $enumDecode(_$EmoteTypeEnumMap, json['type']), + ownerDisplayName: json['ownerDisplayName'] as String?, + ownerUsername: json['ownerUsername'] as String?, + ownerId: json['ownerId'] as String?, +); Map _$EmoteToJson(Emote instance) => { - 'name': instance.name, - 'realName': instance.realName, - 'width': instance.width, - 'height': instance.height, - 'zeroWidth': instance.zeroWidth, - 'url': instance.url, - 'type': _$EmoteTypeEnumMap[instance.type]!, - 'ownerDisplayName': instance.ownerDisplayName, - 'ownerUsername': instance.ownerUsername, - 'ownerId': instance.ownerId, - }; + 'name': instance.name, + 'realName': instance.realName, + 'width': instance.width, + 'height': instance.height, + 'zeroWidth': instance.zeroWidth, + 'url': instance.url, + 'type': _$EmoteTypeEnumMap[instance.type]!, + 'ownerDisplayName': instance.ownerDisplayName, + 'ownerUsername': instance.ownerUsername, + 'ownerId': instance.ownerId, +}; const _$EmoteTypeEnumMap = { EmoteType.twitchBits: 'twitchBits', diff --git a/lib/models/events.dart b/lib/models/events.dart index be3728ef..949e3e55 100644 --- a/lib/models/events.dart +++ b/lib/models/events.dart @@ -10,11 +10,7 @@ class SevenTVEvent { final int? t; final SevenTVEventData d; - const SevenTVEvent({ - required this.op, - this.t, - required this.d, - }); + const SevenTVEvent({required this.op, this.t, required this.d}); factory SevenTVEvent.fromJson(Map json) => _$SevenTVEventFromJson(json); @@ -29,11 +25,7 @@ class SevenTVEventData { @JsonKey(includeToJson: false) final SevenTVEventEmoteSetBody? body; - const SevenTVEventData({ - required this.type, - this.condition, - this.body, - }); + const SevenTVEventData({required this.type, this.condition, this.body}); factory SevenTVEventData.fromJson(Map json) => _$SevenTVEventDataFromJson(json); @@ -46,10 +38,7 @@ class SevenTVEventUpdatedEmote { final Emote7TV? value; final Emote7TV? oldValue; - const SevenTVEventUpdatedEmote({ - this.value, - this.oldValue, - }); + const SevenTVEventUpdatedEmote({this.value, this.oldValue}); factory SevenTVEventUpdatedEmote.fromJson(Map json) => _$SevenTVEventUpdatedEmoteFromJson(json); diff --git a/lib/models/events.g.dart b/lib/models/events.g.dart index 02ea3d4a..33ffff35 100644 --- a/lib/models/events.g.dart +++ b/lib/models/events.g.dart @@ -7,26 +7,13 @@ part of 'events.dart'; // ************************************************************************** SevenTVEvent _$SevenTVEventFromJson(Map json) => SevenTVEvent( - op: (json['op'] as num).toInt(), - t: (json['t'] as num?)?.toInt(), - d: SevenTVEventData.fromJson(json['d'] as Map), - ); + op: (json['op'] as num).toInt(), + t: (json['t'] as num?)?.toInt(), + d: SevenTVEventData.fromJson(json['d'] as Map), +); -Map _$SevenTVEventToJson(SevenTVEvent instance) { - final val = { - 'op': instance.op, - }; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('t', instance.t); - val['d'] = instance.d; - return val; -} +Map _$SevenTVEventToJson(SevenTVEvent instance) => + {'op': instance.op, 't': ?instance.t, 'd': instance.d}; SevenTVEventData _$SevenTVEventDataFromJson(Map json) => SevenTVEventData( @@ -37,36 +24,32 @@ SevenTVEventData _$SevenTVEventDataFromJson(Map json) => body: json['body'] == null ? null : SevenTVEventEmoteSetBody.fromJson( - json['body'] as Map), + json['body'] as Map, + ), ); Map _$SevenTVEventDataToJson(SevenTVEventData instance) => - { - 'type': instance.type, - 'condition': instance.condition, - }; + {'type': instance.type, 'condition': instance.condition}; SevenTVEventUpdatedEmote _$SevenTVEventUpdatedEmoteFromJson( - Map json) => - SevenTVEventUpdatedEmote( - value: json['value'] == null - ? null - : Emote7TV.fromJson(json['value'] as Map), - oldValue: json['old_value'] == null - ? null - : Emote7TV.fromJson(json['old_value'] as Map), - ); + Map json, +) => SevenTVEventUpdatedEmote( + value: json['value'] == null + ? null + : Emote7TV.fromJson(json['value'] as Map), + oldValue: json['old_value'] == null + ? null + : Emote7TV.fromJson(json['old_value'] as Map), +); SevenTVEventEmoteSetBody _$SevenTVEventEmoteSetBodyFromJson( - Map json) => - SevenTVEventEmoteSetBody( - actor: Owner7TV.fromJson(json['actor'] as Map), - pushed: (json['pushed'] as List?) - ?.map((e) => - SevenTVEventUpdatedEmote.fromJson(e as Map)) - .toList(), - pulled: (json['pulled'] as List?) - ?.map((e) => - SevenTVEventUpdatedEmote.fromJson(e as Map)) - .toList(), - ); + Map json, +) => SevenTVEventEmoteSetBody( + actor: Owner7TV.fromJson(json['actor'] as Map), + pushed: (json['pushed'] as List?) + ?.map((e) => SevenTVEventUpdatedEmote.fromJson(e as Map)) + .toList(), + pulled: (json['pulled'] as List?) + ?.map((e) => SevenTVEventUpdatedEmote.fromJson(e as Map)) + .toList(), +); diff --git a/lib/models/followed_channel.dart b/lib/models/followed_channel.dart new file mode 100644 index 00000000..14d299e9 --- /dev/null +++ b/lib/models/followed_channel.dart @@ -0,0 +1,39 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'followed_channel.g.dart'; + +// Object for followed Twitch channels. +@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake) +class FollowedChannel { + final String broadcasterId; + final String broadcasterLogin; + final String broadcasterName; + final DateTime followedAt; + + const FollowedChannel({ + required this.broadcasterId, + required this.broadcasterLogin, + required this.broadcasterName, + required this.followedAt, + }); + + factory FollowedChannel.fromJson(Map json) => + _$FollowedChannelFromJson(json); +} + +// Response object for followed channels API call. +@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake) +class FollowedChannels { + final List data; + final Map pagination; + final int total; + + const FollowedChannels({ + required this.data, + required this.pagination, + required this.total, + }); + + factory FollowedChannels.fromJson(Map json) => + _$FollowedChannelsFromJson(json); +} diff --git a/lib/models/followed_channel.g.dart b/lib/models/followed_channel.g.dart new file mode 100644 index 00000000..d17b7dff --- /dev/null +++ b/lib/models/followed_channel.g.dart @@ -0,0 +1,24 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'followed_channel.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +FollowedChannel _$FollowedChannelFromJson(Map json) => + FollowedChannel( + broadcasterId: json['broadcaster_id'] as String, + broadcasterLogin: json['broadcaster_login'] as String, + broadcasterName: json['broadcaster_name'] as String, + followedAt: DateTime.parse(json['followed_at'] as String), + ); + +FollowedChannels _$FollowedChannelsFromJson(Map json) => + FollowedChannels( + data: (json['data'] as List) + .map((e) => FollowedChannel.fromJson(e as Map)) + .toList(), + pagination: Map.from(json['pagination'] as Map), + total: (json['total'] as num).toInt(), + ); diff --git a/lib/models/irc.dart b/lib/models/irc.dart index ce027904..649da4a8 100644 --- a/lib/models/irc.dart +++ b/lib/models/irc.dart @@ -11,12 +11,23 @@ import 'package:frosty/models/emotes.dart'; import 'package:frosty/models/user.dart'; import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; -import 'package:frosty/utils.dart'; -import 'package:frosty/widgets/cached_image.dart'; -import 'package:frosty/widgets/photo_view.dart'; +import 'package:frosty/utils.dart' as utils; +import 'package:frosty/utils/modal_bottom_sheet.dart'; +import 'package:frosty/widgets/frosty_cached_network_image.dart'; +import 'package:frosty/widgets/frosty_photo_view_dialog.dart'; import 'package:intl/intl.dart'; import 'package:url_launcher/url_launcher.dart'; +// Constants for IRC message rendering +const String _ircActionPrefix = '\x01'; +const String _ircActionSuffix = '\x01'; +const int _ircActionPrefixLength = 8; +const String _invalidUnicodeChar = '\u{E0000}'; +const String _twitchEmoteBaseUrl = 'https://static-cdn.jtvnw.net/emoticons/v2'; +const String _twitchIrcServer = 'tmi.twitch.tv'; +const String _emoteModeSuffix = '/default/dark/3.0'; +const double _emojiSizeOffset = 5.0; + /// The object representation of a Twitch IRC message. class IRCMessage { final String raw; @@ -28,6 +39,8 @@ class IRCMessage { List? split; bool? action; bool? mention; + final VoidCallback? actionCallback; + final String? actionLabel; IRCMessage({ required this.raw, @@ -39,6 +52,8 @@ class IRCMessage { this.split, this.action, this.mention, + this.actionCallback, + this.actionLabel, }); /// Applies the given CLEARCHAT message to the provided messages and buffer. @@ -112,65 +127,41 @@ class IRCMessage { } } - /// Returns an [InlineSpan] list that corresponds to the badges, username, words, and emotes of the given [IRCMessage]. - List generateSpan( - BuildContext context, { + /// Adds timestamp to the span if enabled + void _addTimestamp( + List span, TextStyle? style, - required ChatAssetsStore assetsStore, - required double badgeScale, - required double emoteScale, - required bool launchExternal, - void Function()? onTapName, - void Function(String)? onTapPingedUser, - bool showMessage = true, - bool useReadableColors = false, + TimestampType timestamp, + ) { + if (timestamp == TimestampType.disabled) return; + + final time = + tags['tmi-sent-ts'] ?? DateTime.now().millisecondsSinceEpoch.toString(); + final parsedTime = DateTime.fromMillisecondsSinceEpoch(int.parse(time)); + + final timeText = timestamp == TimestampType.twentyFour + ? '${DateFormat.Hm().format(parsedTime)} ' + : '${DateFormat('h:mm').format(parsedTime)} '; + + span.add( + TextSpan( + text: timeText, + style: style?.copyWith( + color: style.color?.withValues(alpha: 0.5), + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ); + } + + /// Adds historical and channel source badges to the span + void _addHistoricalAndChannelBadges( + BuildContext context, + List span, + double badgeSize, Map? channelIdToUserTwitch, - TimestampType timestamp = TimestampType.disabled, - }) { - final isLightTheme = Theme.of(context).brightness == Brightness.light; - - final emoteToObject = assetsStore.emoteToObject; - final twitchBadgeToObject = assetsStore.twitchBadgesToObject; - final ffzUserToBadges = assetsStore.userToFFZBadges; - final sevenTVUserToBadges = assetsStore.userTo7TVBadges; - final bttvUserToBadge = assetsStore.userToBTTVBadges; - final ffzRoomInfo = assetsStore.ffzRoomInfo; - final badgeSize = defaultBadgeSize * badgeScale; - final emoteSize = defaultEmoteSize * emoteScale; - - // The span list that will be used to render the chat message - final span = []; - - if (timestamp != TimestampType.disabled) { - final time = tags['tmi-sent-ts'] ?? - DateTime.now().millisecondsSinceEpoch.toString(); - final parsedTime = DateTime.fromMillisecondsSinceEpoch(int.parse(time)); - - if (timestamp == TimestampType.twentyFour) { - span.add( - TextSpan( - text: '${DateFormat.Hm().format(parsedTime)} ', - style: style?.copyWith( - color: style.color?.withValues(alpha: 0.5), - fontFeatures: [FontFeature.tabularFigures()], - ), - ), - ); - } - - if (timestamp == TimestampType.twelve) { - span.add( - TextSpan( - text: '${DateFormat('h:mm').format(parsedTime)} ', - style: style?.copyWith( - color: style.color?.withValues(alpha: 0.5), - fontFeatures: [FontFeature.tabularFigures()], - ), - ), - ); - } - } - + String? currentChannelId, + ) { final isHistorical = tags['historical'] == '1'; if (isHistorical) { span.add( @@ -180,7 +171,13 @@ class IRCMessage { message: 'Historical message', preferBelow: false, triggerMode: TooltipTriggerMode.tap, - child: Icon(Icons.history_rounded, size: badgeSize), + child: Icon( + Icons.history_rounded, + size: badgeSize, + color: + Theme.of(context).iconTheme.color?.withValues(alpha: 0.5) ?? + Colors.grey.withValues(alpha: 0.5), + ), ), ), ); @@ -188,17 +185,19 @@ class IRCMessage { } final sourceChannelId = tags['source-room-id'] ?? tags['room-id']; - final sourceChannelUser = channelIdToUserTwitch != null - ? channelIdToUserTwitch[sourceChannelId] - : null; + final sourceChannelUser = channelIdToUserTwitch?[sourceChannelId]; if (sourceChannelUser != null) { + final isCurrentChannel = sourceChannelId == currentChannelId; + span.add( WidgetSpan( alignment: PlaceholderAlignment.middle, child: Tooltip( triggerMode: TooltipTriggerMode.tap, preferBelow: false, - message: 'Sent from ${sourceChannelUser.displayName}', + message: isCurrentChannel + ? 'Sent from ${sourceChannelUser.displayName} (current channel)' + : 'Sent from ${sourceChannelUser.displayName}', child: CachedNetworkImage( cacheManager: CustomCacheManager.instance, imageUrl: sourceChannelUser.profileImageUrl, @@ -207,8 +206,22 @@ class IRCMessage { height: badgeSize, decoration: BoxDecoration( shape: BoxShape.circle, - image: - DecorationImage(image: imageProvider, fit: BoxFit.cover), + border: isCurrentChannel + ? Border.all( + color: Theme.of(context).colorScheme.primary, + width: 1.5, + ) + : null, + ), + child: Container( + margin: isCurrentChannel ? const EdgeInsets.all(1.5) : null, + decoration: BoxDecoration( + shape: BoxShape.circle, + image: DecorationImage( + image: imageProvider, + fit: BoxFit.cover, + ), + ), ), ), placeholder: (context, url) => Container( @@ -216,7 +229,12 @@ class IRCMessage { height: badgeSize, decoration: BoxDecoration( shape: BoxShape.circle, - color: Colors.grey, + border: isCurrentChannel + ? Border.all( + color: Theme.of(context).colorScheme.primary, + width: 1.5, + ) + : null, ), ), ), @@ -225,32 +243,47 @@ class IRCMessage { ); span.add(const TextSpan(text: ' ')); } + } - // Indicator to skip adding the bot badges later when adding the rest of FFZ badges. + /// Adds all user badges (Twitch, FFZ, BTTV, 7TV) to the span + bool _addUserBadges( + BuildContext context, + List span, + ChatAssetsStore assetsStore, + double badgeSize, + bool launchExternal, + bool isHistorical, + ) { var skipBot = false; + final twitchBadgeToObject = assetsStore.twitchBadgesToObject; + final ffzUserToBadges = assetsStore.userToFFZBadges; + final sevenTVUserToBadges = assetsStore.userTo7TVBadges; + final bttvUserToBadge = assetsStore.userToBTTVBadges; + final ffzRoomInfo = assetsStore.ffzRoomInfo; final ffzUserBadges = ffzUserToBadges[tags['user-id']]; final twitchBadges = tags['badges']?.split(','); - // Pasrse and add the Twitch badges to the span if they exist. + + // Add Twitch badges if (twitchBadges != null) { for (final badge in twitchBadges) { final badgeInfo = twitchBadgeToObject[badge]; if (badgeInfo != null) { var badgeUrl = badgeInfo.url; - // Add custom FFZ mod badge if it exists. + // Handle custom FFZ mod badge if (badgeInfo.name == 'Moderator' && (ffzUserBadges != null || ffzRoomInfo?.modUrls != null)) { - // Check if mod is bot. - final botBadge = ffzUserBadges - ?.firstWhereOrNull((element) => element.name == 'Bot'); + final botBadge = ffzUserBadges?.firstWhereOrNull( + (element) => element.name == 'Bot', + ); - // Check if user has bot badge or room has custom FFZ mod badges if (botBadge != null) { badgeUrl = botBadge.url; skipBot = true; } else if (ffzRoomInfo?.modUrls != null) { - badgeUrl = ffzRoomInfo!.modUrls?.url4x ?? + badgeUrl = + ffzRoomInfo!.modUrls?.url4x ?? ffzRoomInfo.modUrls?.url2x ?? ffzRoomInfo.modUrls!.url1x; } @@ -274,9 +307,10 @@ class IRCMessage { continue; } - // Add custom FFZ vip badge if it exists + // Handle custom FFZ VIP badge if (badgeInfo.name == 'VIP' && ffzRoomInfo?.vipBadge != null) { - badgeUrl = ffzRoomInfo!.vipBadge?.url4x ?? + badgeUrl = + ffzRoomInfo!.vipBadge?.url4x ?? ffzRoomInfo.vipBadge?.url2x ?? ffzRoomInfo.vipBadge!.url1x; } @@ -300,7 +334,7 @@ class IRCMessage { } } - // Add FFZ badges to span + // Add FFZ badges if (ffzUserBadges != null) { for (final badge in ffzUserBadges) { final color = Color(int.parse(badge.color!.replaceFirst('#', '0xFF'))); @@ -335,7 +369,7 @@ class IRCMessage { } } - // Add BTTV badges to span + // Add BTTV badges final userBTTVBadge = bttvUserToBadge[tags['user-id']]; if (userBTTVBadge != null) { span.add( @@ -350,7 +384,7 @@ class IRCMessage { span.add(const TextSpan(text: ' ')); } - // Add 7TV badges to end of badges span + // Add 7TV badges final user7TVBadges = sevenTVUserToBadges[tags['user-id']]; if (user7TVBadges != null) { for (final badge in user7TVBadges) { @@ -366,209 +400,309 @@ class IRCMessage { } } + return skipBot; + } + + /// Adds the username to the span with proper color and styling + void _addUsername( + List span, + BuildContext context, + void Function()? onTapName, + ) { var color = Color( int.parse((tags['color'] ?? '#868686').replaceFirst('#', '0xFF')), ); - if (useReadableColors) { - final hsl = HSLColor.fromColor(color); - if (isLightTheme == true) { - if (hsl.lightness >= 0.5) { - color = hsl - .withLightness(hsl.lightness + ((0 - hsl.lightness) * 0.5)) - .toColor(); - } - } else { - if (hsl.lightness <= 0.5) { - color = hsl - .withLightness(hsl.lightness + ((1 - hsl.lightness) * 0.5)) - .toColor(); - } - } - } + // Adjust color for theme contrast using the common utility + color = utils.adjustChatNameColor(context, color); - // Add the display name (username) to the span and apply the onLongPressName callback. final displayName = tags['display-name']!; span.add( TextSpan( - text: user != null ? getReadableName(displayName, user!) : displayName, - style: TextStyle( - color: color, - fontWeight: FontWeight.bold, - ), + text: user != null + ? utils.getReadableName(displayName, user!) + : displayName, + style: TextStyle(color: color, fontWeight: FontWeight.bold), recognizer: TapGestureRecognizer()..onTap = onTapName, ), ); - // Add the colon separator between the username and their message to the span. + // Add the colon separator between the username and their message if (action == false) span.add(const TextSpan(text: ':')); + } - // Italicize the text if it was called with an IRC Action (e.g., "/me"). - final textStyle = - action == true ? const TextStyle(fontStyle: FontStyle.italic) : style; - + /// Adds message content with emotes, emojis and text processing + void _addMessageContent( + BuildContext context, + List span, + Map emoteToObject, + double emoteSize, + double emoteScale, + bool showMessage, + bool launchExternal, + TextStyle? textStyle, + void Function(String)? onTapPingedUser, + void Function()? onTapDeletedMessage, + ) { if (!showMessage) { - span.add(const TextSpan(text: ' ')); - } else { - // Check if the message is a reply. If it is, remove the reply username (first word) from the message. - final words = tags.containsKey('reply-parent-display-name') - ? split?.sublist(1) - : split; + span.add( + TextSpan( + text: ' ', + style: onTapDeletedMessage != null + ? textStyle?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + ) + : null, + recognizer: onTapDeletedMessage != null + ? (TapGestureRecognizer()..onTap = onTapDeletedMessage) + : null, + ), + ); + return; + } - // Add the message and any emotes to the span. - if (words != null) { - // Keep a local span which will be reversed and added to the final span. - final localSpan = []; + // Check if the message is a reply. If it is, remove the reply username from the message. + final words = tags.containsKey('reply-parent-display-name') + ? split?.sublist(1) + : split; - // Iterate through the words in reverse-order. - // Index starts at the last word in the message. - var index = words.length - 1; + if (words != null) { + // Keep a local span which will be reversed and added to the final span. + final localSpan = []; - // Terminate after the first word in the message reached. - while (index != -1) { - // Check if current word is a valid emote. - final word = words[index]; - final emote = emoteToObject[word] ?? localEmotes?[word]; + // Iterate through the words in reverse-order. + var index = words.length - 1; - if (emote != null) { - // If the emote is zero-width and not the first word, process the stack. - if (emote.zeroWidth && index != 0) { - final emoteStack = []; + // Terminate after the first word in the message reached. + while (index != -1) { + final word = words[index]; + final emote = emoteToObject[word] ?? localEmotes?[word]; - // Handle stacking consecutive zero-width emotes. - Emote? nextEmote = emote; - while (nextEmote != null && nextEmote.zeroWidth && index != 0) { - emoteStack.add(nextEmote); - index--; - nextEmote = - emoteToObject[words[index]] ?? localEmotes?[words[index]]; - } - - // If there's one more emote thats NOT zero-width, add it to the base of the stack. - if (nextEmote != null) emoteStack.add(nextEmote); - - // Check if the next word is an emoji to be added to the stack. - final nextWordIsEmoji = regexEmoji.hasMatch(words[index]); - - // Create the stack of emotes with the base emoji if there is one. - // Will be used as the children of the Stack widget. - final children = [ - if (nextWordIsEmoji) - Text( - words[index], - style: textStyle?.copyWith(fontSize: emoteSize - 5), - ), - ...emoteStack.reversed.map( - (emote) => FrostyCachedNetworkImage( - imageUrl: emote.url, - height: emote.height != null - ? emote.height! * emoteScale - : emoteSize, - width: emote.width != null - ? emote.width! * emoteScale - : emoteSize, - useFade: false, - ), - ), - ]; - - // Create the message for the tooltip - final message = emoteStack.reversed.map( - (emote) => emote.name, - ); - final emoji = words[index]; - localSpan.add( - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: InkWell( - onTap: () => _showAssetDetailsBottomSheet( - context, - leading: Stack( - alignment: AlignmentDirectional.center, - children: [ - if (nextWordIsEmoji) - Text( - emoji, - style: textStyle?.copyWith(fontSize: 40), - ), - ...emoteStack.reversed.map( - (emote) => FrostyCachedNetworkImage( - imageUrl: emote.url, - width: 56, - ), - ), - ], - ), - url: emoteStack.last.url, - title: nextWordIsEmoji - ? emoji - : '${emoteStack.last.name} (${emoteStack.last.type})', - subtitle: Text( - 'with ${nextWordIsEmoji ? message.join(' + ') : message.skip(1).join(' + ')}', - ), - launchExternal: launchExternal, - ), - child: Stack( - alignment: AlignmentDirectional.center, - children: children, - ), - ), - ), - ); - - // If the next word is neither an emote or emoji, add it as a text span. - if (nextEmote == null && !nextWordIsEmoji) { - localSpan.add(const TextSpan(text: ' ')); - localSpan.add( - _createTextSpan( - text: words[index], - style: textStyle, - launchExternal: launchExternal, - onTapPingedUser: onTapPingedUser, - ), - ); - } - } else { - localSpan.add( - _createEmoteSpan( - context, - emote: emote, - height: emote.height != null - ? emote.height! * emoteScale - : emoteSize, - width: emote.width != null ? emote.width! * emoteScale : null, - launchExternal: launchExternal, - ), - ); - } + if (emote != null) { + // If the emote is zero-width and not the first word, process the stack. + if (emote.zeroWidth && index != 0) { + index = _processZeroWidthEmoteStack( + context, + localSpan, + words, + index, + emote, + emoteToObject, + emoteSize, + emoteScale, + textStyle, + launchExternal, + ); } else { - if (regexEmoji.hasMatch(word)) { - localSpan.add( - _createEmojiSpan( - emoji: word, - style: textStyle?.copyWith(fontSize: emoteSize - 5), - ), - ); - } else { - localSpan.add( - _createTextSpan( - text: word, - style: textStyle, - launchExternal: launchExternal, - onTapPingedUser: onTapPingedUser, - ), - ); - } + localSpan.add( + _createEmoteSpan( + context, + emote: emote, + height: emote.height != null + ? emote.height! * emoteScale + : emoteSize, + width: emote.width != null ? emote.width! * emoteScale : null, + launchExternal: launchExternal, + ), + ); + localSpan.add(const TextSpan(text: ' ')); + index--; + } + } else { + if (regexEmoji.hasMatch(word)) { + localSpan.add( + _createEmojiSpan( + emoji: word, + style: textStyle?.copyWith( + fontSize: emoteSize - _emojiSizeOffset, + ), + ), + ); + } else { + localSpan.add( + _createTextSpan( + text: word, + style: textStyle, + launchExternal: launchExternal, + onTapPingedUser: onTapPingedUser, + ), + ); } - localSpan.add(const TextSpan(text: ' ')); index--; } - - // Add the the local span reversed to the final span. - span.addAll(localSpan.reversed); } + + // Add the local span reversed to the final span. + span.addAll(localSpan.reversed); } + } + + /// Handles zero-width emote stacking logic and returns updated index + int _processZeroWidthEmoteStack( + BuildContext context, + List localSpan, + List words, + int startIndex, + Emote startEmote, + Map emoteToObject, + double emoteSize, + double emoteScale, + TextStyle? textStyle, + bool launchExternal, + ) { + final emoteStack = []; + var index = startIndex; + + // Handle stacking consecutive zero-width emotes. + Emote? nextEmote = startEmote; + while (nextEmote != null && nextEmote.zeroWidth && index != 0) { + emoteStack.add(nextEmote); + index--; + nextEmote = emoteToObject[words[index]] ?? localEmotes?[words[index]]; + } + + // If there's one more emote that's NOT zero-width, add it to the base of the stack. + if (nextEmote != null) emoteStack.add(nextEmote); + + // Check if the next word is an emoji to be added to the stack. + final nextWordIsEmoji = regexEmoji.hasMatch(words[index]); + + // Create the stack of emotes with the base emoji if there is one. + final children = [ + if (nextWordIsEmoji) + Text( + words[index], + style: textStyle?.copyWith(fontSize: emoteSize - _emojiSizeOffset), + ), + ...emoteStack.reversed.map( + (emote) => FrostyCachedNetworkImage( + imageUrl: emote.url, + height: emote.height != null ? emote.height! * emoteScale : emoteSize, + width: emote.width != null ? emote.width! * emoteScale : emoteSize, + useFade: false, + ), + ), + ]; + + // Create the message for the tooltip + final message = emoteStack.reversed.map((emote) => emote.name); + final emoji = words[index]; + + localSpan.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: InkWell( + onTap: () => _showAssetDetailsBottomSheet( + context, + leading: Stack( + alignment: AlignmentDirectional.center, + children: [ + if (nextWordIsEmoji) + Text(emoji, style: textStyle?.copyWith(fontSize: 40)), + ...emoteStack.reversed.map( + (emote) => + FrostyCachedNetworkImage(imageUrl: emote.url, width: 56), + ), + ], + ), + url: emoteStack.last.url, + title: nextWordIsEmoji + ? emoji + : '${emoteStack.last.name} (${emoteStack.last.type})', + subtitle: Text( + 'with ${nextWordIsEmoji ? message.join(' + ') : message.skip(1).join(' + ')}', + ), + launchExternal: launchExternal, + ), + child: Stack( + alignment: AlignmentDirectional.center, + children: children, + ), + ), + ), + ); + + // If the next word is neither an emote nor emoji, add it as a text span. + if (nextEmote == null && !nextWordIsEmoji) { + localSpan.add(const TextSpan(text: ' ')); + localSpan.add( + _createTextSpan( + text: words[index], + style: textStyle, + launchExternal: launchExternal, + ), + ); + } + + // Add space after the zero-width emote stack (like all other elements) + localSpan.add(const TextSpan(text: ' ')); + + return index - 1; + } + + /// Returns an [InlineSpan] list that corresponds to the badges, username, words, and emotes of the given [IRCMessage]. + List generateSpan( + BuildContext context, { + TextStyle? style, + required ChatAssetsStore assetsStore, + required double badgeScale, + required double emoteScale, + required bool launchExternal, + void Function()? onTapName, + void Function(String)? onTapPingedUser, + void Function()? onTapDeletedMessage, + bool showMessage = true, + Map? channelIdToUserTwitch, + TimestampType timestamp = TimestampType.disabled, + String? currentChannelId, + }) { + final emoteToObject = assetsStore.emoteToObject; + final badgeSize = defaultBadgeSize * badgeScale; + final emoteSize = defaultEmoteSize * emoteScale; + + // The span list that will be used to render the chat message + final span = []; + + _addTimestamp(span, style, timestamp); + _addHistoricalAndChannelBadges( + context, + span, + badgeSize, + channelIdToUserTwitch, + currentChannelId, + ); + + final isHistorical = tags['historical'] == '1'; + _addUserBadges( + context, + span, + assetsStore, + badgeSize, + launchExternal, + isHistorical, + ); + + _addUsername(span, context, onTapName); + + // Italicize the text if it was called with an IRC Action (e.g., "/me"). + final textStyle = action == true + ? const TextStyle(fontStyle: FontStyle.italic) + : style; + + _addMessageContent( + context, + span, + emoteToObject, + emoteSize, + emoteScale, + showMessage, + launchExternal, + textStyle, + onTapPingedUser, + onTapDeletedMessage, + ); return span; } @@ -579,10 +713,7 @@ class IRCMessage { }) { return WidgetSpan( alignment: PlaceholderAlignment.middle, - child: Text( - emoji, - style: style, - ), + child: Text(emoji, style: style), ); } @@ -603,11 +734,7 @@ class IRCMessage { ), ); } else if (isSvg == true) { - return SvgPicture.network( - badge.url, - height: size, - width: size, - ); + return SvgPicture.network(badge.url, height: size, width: size); } else { return FrostyCachedNetworkImage( imageUrl: badge.url, @@ -706,11 +833,11 @@ class IRCMessage { ), recognizer: TapGestureRecognizer() ..onTap = () => launchUrl( - Uri.parse(text), - mode: launchExternal - ? LaunchMode.externalApplication - : LaunchMode.inAppBrowserView, - ), + Uri.parse(text), + mode: launchExternal + ? LaunchMode.externalApplication + : LaunchMode.inAppBrowserView, + ), ); } else { return TextSpan(text: text, style: style); @@ -724,10 +851,7 @@ class IRCMessage { }) { _showAssetDetailsBottomSheet( context, - leading: FrostyCachedNetworkImage( - imageUrl: emote.url, - width: 56, - ), + leading: FrostyCachedNetworkImage(imageUrl: emote.url, width: 56), url: emote.url, title: emote.realName != null ? '${emote.name} (${emote.realName})' @@ -738,7 +862,7 @@ class IRCMessage { Text(emote.type.toString()), if (emote.ownerDisplayName != null && emote.ownerUsername != null) Text( - 'by ${getReadableName(emote.ownerDisplayName!, emote.ownerUsername!)}', + 'by ${utils.getReadableName(emote.ownerDisplayName!, emote.ownerUsername!)}', ), ], ), @@ -755,7 +879,7 @@ class IRCMessage { required bool launchExternal, bool showCopyName = true, }) { - showModalBottomSheet( + showModalBottomSheetWithProperFocus( context: context, builder: (context) => ListView( shrinkWrap: true, @@ -771,12 +895,11 @@ class IRCMessage { ), title: Text( title, - style: const TextStyle( - fontWeight: FontWeight.w600, - ), + style: const TextStyle(fontWeight: FontWeight.w600), ), subtitle: subtitle, ), + const Divider(indent: 16, endIndent: 16), if (showCopyName) ListTile( leading: const Icon(Icons.copy_rounded), @@ -830,8 +953,9 @@ class IRCMessage { // Get the tags substring and escape characters. // IRC messages escape spaces with \s. - final tags = - whole.substring(1, tagAndIrcMessageDivider).replaceAll('\\s', ' '); + final tags = whole + .substring(1, tagAndIrcMessageDivider) + .replaceAll('\\s', ' '); // Next, parse and map the tags. final mappedTags = {}; @@ -858,7 +982,7 @@ class IRCMessage { // If the username exists, set it. // tmi.twitch.tv means the message was sent by Twitch rather than a user, so will be irrelevant. - final String? user = splitMessage[0] == 'tmi.twitch.tv' + final String? user = splitMessage[0] == _twitchIrcServer ? mappedTags['login'] : splitMessage[0].substring(0, splitMessage[0].indexOf('!')); @@ -886,8 +1010,10 @@ class IRCMessage { for (final emoteIdAndPosition in emotes) { final indexBetweenIdAndPositions = emoteIdAndPosition.indexOf(':'); - final emoteId = - emoteIdAndPosition.substring(0, indexBetweenIdAndPositions); + final emoteId = emoteIdAndPosition.substring( + 0, + indexBetweenIdAndPositions, + ); // Parse the range in order to extract the associated word. // If there are more than one indices, use the first one. @@ -913,8 +1039,7 @@ class IRCMessage { localEmotes[emoteWord] = Emote( name: emoteWord, zeroWidth: false, - url: - 'https://static-cdn.jtvnw.net/emoticons/v2/$emoteId/default/dark/3.0', + url: '$_twitchEmoteBaseUrl/$emoteId$_emoteModeSuffix', type: EmoteType.twitchSub, ); } @@ -925,9 +1050,10 @@ class IRCMessage { List? split; if (message != null) { // Check if IRC actions like "/me" were called. - if (message.startsWith('\x01') && message.endsWith('\x01')) { + if (message.startsWith(_ircActionPrefix) && + message.endsWith(_ircActionSuffix)) { action = true; - message = message.substring(8, message.length - 1); + message = message.substring(_ircActionPrefixLength, message.length - 1); } // Check if the message mentions the logged-in user @@ -941,7 +1067,7 @@ class IRCMessage { // Also remove any "INVALID/UNDEFINED" Unicode characters. // Rendering this character on iOS shows a question mark inside a square. // This character is used by some clients to bypass restrictions on repeating message. - .map((word) => word.replaceAll('\u{E0000}', '').trim()) + .map((word) => word.replaceAll(_invalidUnicodeChar, '').trim()) .where((element) => element != '') .join(' '); @@ -1019,12 +1145,18 @@ class IRCMessage { ); } - factory IRCMessage.createNotice({required String message}) => IRCMessage( - raw: '', - tags: {}, - command: Command.notice, - message: message, - ); + factory IRCMessage.createNotice({ + required String message, + VoidCallback? actionCallback, + String? actionLabel, + }) => IRCMessage( + raw: '', + tags: {}, + command: Command.notice, + message: message, + actionCallback: actionCallback, + actionLabel: actionLabel, + ); } /// The object representation of the IRC ROOMSTATE message. @@ -1045,12 +1177,12 @@ class ROOMSTATE { /// Create a new copy with the parameters from the provided [IRCMessage] ROOMSTATE fromIRCMessage(IRCMessage ircMessage) => ROOMSTATE( - emoteOnly: ircMessage.tags['emote-only'] ?? emoteOnly, - followersOnly: ircMessage.tags['followers-only'] ?? followersOnly, - r9k: ircMessage.tags['r9k'] ?? r9k, - slowMode: ircMessage.tags['slow'] ?? slowMode, - subMode: ircMessage.tags['subs-only'] ?? subMode, - ); + emoteOnly: ircMessage.tags['emote-only'] ?? emoteOnly, + followersOnly: ircMessage.tags['followers-only'] ?? followersOnly, + r9k: ircMessage.tags['r9k'] ?? r9k, + slowMode: ircMessage.tags['slow'] ?? slowMode, + subMode: ircMessage.tags['subs-only'] ?? subMode, + ); } class USERSTATE { @@ -1069,12 +1201,12 @@ class USERSTATE { }); USERSTATE fromIRCMessage(IRCMessage ircMessage) => USERSTATE( - raw: ircMessage.raw, - color: ircMessage.tags['color'] ?? color, - displayName: ircMessage.tags['display-name'] ?? displayName, - mod: ircMessage.tags['mod'] == '0' ? false : true, - subscriber: ircMessage.tags['subscriber'] == '0' ? false : true, - ); + raw: ircMessage.raw, + color: ircMessage.tags['color'] ?? color, + displayName: ircMessage.tags['display-name'] ?? displayName, + mod: ircMessage.tags['mod'] == '0' ? false : true, + subscriber: ircMessage.tags['subscriber'] == '0' ? false : true, + ); } /// The possible types of Twitch IRC commands. diff --git a/lib/models/shared_chat_session.g.dart b/lib/models/shared_chat_session.g.dart index 44a4acfb..8148c377 100644 --- a/lib/models/shared_chat_session.g.dart +++ b/lib/models/shared_chat_session.g.dart @@ -17,6 +17,5 @@ SharedChatSession _$SharedChatSessionFromJson(Map json) => updatedAt: json['updated_at'] as String, ); -Participant _$ParticipantFromJson(Map json) => Participant( - broadcasterId: json['broadcaster_id'] as String, - ); +Participant _$ParticipantFromJson(Map json) => + Participant(broadcasterId: json['broadcaster_id'] as String); diff --git a/lib/models/stream.dart b/lib/models/stream.dart index 05edf653..0f433ff6 100644 --- a/lib/models/stream.dart +++ b/lib/models/stream.dart @@ -36,10 +36,7 @@ class StreamsTwitch { final List data; final Map pagination; - const StreamsTwitch( - this.data, - this.pagination, - ); + const StreamsTwitch(this.data, this.pagination); factory StreamsTwitch.fromJson(Map json) => _$StreamsTwitchFromJson(json); diff --git a/lib/models/stream.g.dart b/lib/models/stream.g.dart index 0cfaee53..fe2fe565 100644 --- a/lib/models/stream.g.dart +++ b/lib/models/stream.g.dart @@ -7,16 +7,16 @@ part of 'stream.dart'; // ************************************************************************** StreamTwitch _$StreamTwitchFromJson(Map json) => StreamTwitch( - json['user_id'] as String, - json['user_login'] as String, - json['user_name'] as String, - json['game_id'] as String, - json['game_name'] as String, - json['title'] as String, - (json['viewer_count'] as num).toInt(), - json['started_at'] as String, - json['thumbnail_url'] as String, - ); + json['user_id'] as String, + json['user_login'] as String, + json['user_name'] as String, + json['game_id'] as String, + json['game_name'] as String, + json['title'] as String, + (json['viewer_count'] as num).toInt(), + json['started_at'] as String, + json['thumbnail_url'] as String, +); StreamsTwitch _$StreamsTwitchFromJson(Map json) => StreamsTwitch( diff --git a/lib/models/user.dart b/lib/models/user.dart index 59030217..d97e0fb7 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -10,12 +10,7 @@ class UserTwitch { final String displayName; final String profileImageUrl; - const UserTwitch( - this.id, - this.login, - this.displayName, - this.profileImageUrl, - ); + const UserTwitch(this.id, this.login, this.displayName, this.profileImageUrl); factory UserTwitch.fromJson(Map json) => _$UserTwitchFromJson(json); @@ -27,11 +22,7 @@ class UserBlockedTwitch { final String userLogin; final String displayName; - const UserBlockedTwitch( - this.userId, - this.userLogin, - this.displayName, - ); + const UserBlockedTwitch(this.userId, this.userLogin, this.displayName); factory UserBlockedTwitch.fromJson(Map json) => _$UserBlockedTwitchFromJson(json); diff --git a/lib/models/user.g.dart b/lib/models/user.g.dart index ffeae6d0..e46b0b83 100644 --- a/lib/models/user.g.dart +++ b/lib/models/user.g.dart @@ -7,11 +7,11 @@ part of 'user.dart'; // ************************************************************************** UserTwitch _$UserTwitchFromJson(Map json) => UserTwitch( - json['id'] as String, - json['login'] as String, - json['display_name'] as String, - json['profile_image_url'] as String, - ); + json['id'] as String, + json['login'] as String, + json['display_name'] as String, + json['profile_image_url'] as String, +); UserBlockedTwitch _$UserBlockedTwitchFromJson(Map json) => UserBlockedTwitch( diff --git a/lib/screens/channel/channel.dart b/lib/screens/channel/channel.dart index 62ccd60d..af0fdc70 100644 --- a/lib/screens/channel/channel.dart +++ b/lib/screens/channel/channel.dart @@ -3,24 +3,19 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:frosty/apis/bttv_api.dart'; -import 'package:frosty/apis/ffz_api.dart'; -import 'package:frosty/apis/seventv_api.dart'; -import 'package:frosty/apis/twitch_api.dart'; -import 'package:frosty/screens/channel/chat/chat.dart'; -import 'package:frosty/screens/channel/chat/details/chat_details_store.dart'; -import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; +import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart'; +import 'package:frosty/screens/channel/chat/widgets/chat_tabs.dart'; +import 'package:frosty/screens/channel/video/stream_info_bar.dart'; import 'package:frosty/screens/channel/video/video.dart'; -import 'package:frosty/screens/channel/video/video_bar.dart'; import 'package:frosty/screens/channel/video/video_overlay.dart'; import 'package:frosty/screens/channel/video/video_store.dart'; -import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/theme.dart'; -import 'package:frosty/utils.dart'; -import 'package:frosty/widgets/app_bar.dart'; -import 'package:frosty/widgets/notification.dart'; +import 'package:frosty/utils/context_extensions.dart'; +import 'package:frosty/widgets/blurred_container.dart'; +import 'package:frosty/widgets/draggable_divider.dart'; +import 'package:frosty/widgets/frosty_notification.dart'; import 'package:provider/provider.dart'; import 'package:simple_pip_mode/actions/pip_actions_layout.dart'; import 'package:simple_pip_mode/pip_widget.dart'; @@ -42,59 +37,179 @@ class VideoChat extends StatefulWidget { State createState() => _VideoChatState(); } -class _VideoChatState extends State { +class _VideoChatState extends State + with SingleTickerProviderStateMixin, WidgetsBindingObserver { final _videoKey = GlobalKey(); final _chatKey = GlobalKey(); - late final ChatStore _chatStore = ChatStore( - twitchApi: context.read(), - channelName: widget.userLogin, - channelId: widget.userId, - displayName: widget.userName, - auth: context.read(), - settings: context.read(), - chatDetailsStore: ChatDetailsStore( - twitchApi: context.read(), - channelName: widget.userLogin, - ), - assetsStore: ChatAssetsStore( - twitchApi: context.read(), - ffzApi: context.read(), - bttvApi: context.read(), - sevenTVApi: context.read(), - ), + // PiP drag state - essential only + double _pipDragDistance = 0; + bool _isPipDragging = false; + bool _isInPipTriggerZone = + false; // Track when in trigger zone for haptic feedback + + // Divider drag state for synchronizing animation + bool _isDividerDragging = false; + + // Essential constants for good UX balance + static const double _pipTriggerDistance = 80; + static const double _pipMaxDragDistance = 150; + + // Animation controller for smooth spring-back + late AnimationController _animationController; + late Animation _springBackAnimation; + + late final ChatTabsStore _chatTabsStore = ChatTabsStore( + twitchApi: context.twitchApi, + bttvApi: context.bttvApi, + ffzApi: context.ffzApi, + sevenTVApi: context.sevenTVApi, + authStore: context.authStore, + settingsStore: context.settingsStore, + globalAssetsStore: context.globalAssetsStore, + primaryChannelId: widget.userId, + primaryChannelLogin: widget.userLogin, + primaryDisplayName: widget.userName, ); late final VideoStore _videoStore = VideoStore( userLogin: widget.userLogin, - twitchApi: context.read(), - authStore: context.read(), - settingsStore: context.read(), + userId: widget.userId, + twitchApi: context.twitchApi, + authStore: context.authStore, + settingsStore: context.settingsStore, ); @override - Widget build(BuildContext context) { - final orientation = MediaQuery.of(context).orientation; + void initState() { + super.initState(); - final settingsStore = _chatStore.settings; - - final appBar = FrostyAppBar( - title: Text( - getReadableName(_chatStore.displayName, _chatStore.channelName), - ), + // Initialize animation controller for smooth drag interactions + _animationController = AnimationController( + duration: const Duration(milliseconds: 250), + vsync: this, ); + // Spring-back animation with smooth easing + _springBackAnimation = + Tween(begin: 0, end: 0).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeOut), + )..addListener(() { + setState(() { + _pipDragDistance = _springBackAnimation.value; + }); + }); + + // Register as observer for app lifecycle events + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + + if (state == AppLifecycleState.resumed) { + _videoStore.handleAppResume(); + } + } + + void _handlePipDragStart(DragStartDetails details) { + // Disable drag gesture when already in PiP mode or video is not playing + if (_videoStore.isInPipMode || _videoStore.paused) return; + + _animationController.stop(); // Stop any ongoing animation + setState(() { + _isPipDragging = true; + _pipDragDistance = 0; + _isInPipTriggerZone = false; + }); + } + + void _handlePipDragUpdate(DragUpdateDetails details) { + if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) { + return; + } + + setState(() { + _pipDragDistance += details.delta.dy; + _pipDragDistance = _pipDragDistance.clamp(0, _pipMaxDragDistance); + + // Check if we've entered or exited the trigger zone for haptic feedback + final wasInTriggerZone = _isInPipTriggerZone; + _isInPipTriggerZone = _pipDragDistance >= _pipTriggerDistance; + + // Provide haptic feedback when entering the trigger zone + if (!wasInTriggerZone && _isInPipTriggerZone) { + HapticFeedback.mediumImpact(); // Entering trigger zone + } + // Provide subtle haptic feedback when exiting the trigger zone + else if (wasInTriggerZone && !_isInPipTriggerZone) { + HapticFeedback.lightImpact(); // Exiting trigger zone + } + }); + } + + void _handlePipDragEnd(DragEndDetails details) { + if (!_isPipDragging || _videoStore.isInPipMode || _videoStore.paused) { + return; + } + + final velocity = details.velocity.pixelsPerSecond.dy; + final shouldTriggerPip = + _pipDragDistance >= _pipTriggerDistance || + velocity > 600; // Simple velocity threshold + + if (shouldTriggerPip) { + // Simple haptic feedback on success + HapticFeedback.mediumImpact(); + _videoStore.requestPictureInPicture(); + _resetDragState(); + } else { + // Animate back to original position + _animateSpringBack(); + } + } + + void _handlePipDragCancel() { + if (!_isPipDragging) return; + _animateSpringBack(); + } + + void _resetDragState() { + setState(() { + _isPipDragging = false; + _pipDragDistance = 0; + _isInPipTriggerZone = false; + }); + } + + void _animateSpringBack() { + _springBackAnimation = Tween(begin: _pipDragDistance, end: 0) + .animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeOut), + ); + + _animationController.reset(); + _animationController.forward().then((_) { + _resetDragState(); + }); + } + + /// Convenience getter for the currently active chat store. + ChatStore get _chatStore => _chatTabsStore.activeChatStore; + + @override + Widget build(BuildContext context) { + final settingsStore = _chatTabsStore.settingsStore; + final player = GestureDetector( onLongPress: _videoStore.handleToggleOverlay, - child: Video( - key: _videoKey, - videoStore: _videoStore, - ), + child: Video(key: _videoKey, videoStore: _videoStore), ); final overlay = GestureDetector( onLongPress: _videoStore.handleToggleOverlay, - onDoubleTap: orientation == Orientation.landscape + onDoubleTap: context.isLandscape ? () => settingsStore.fullScreen = !settingsStore.fullScreen : null, onTap: () { @@ -102,7 +217,7 @@ class _VideoChatState extends State { _chatStore.assetsStore.showEmoteMenu = false; } else { if (_chatStore.textFieldFocusNode.hasFocus) { - _chatStore.textFieldFocusNode.unfocus(); + _chatStore.unfocusInput(); } else { _videoStore.handleVideoTap(); } @@ -124,11 +239,8 @@ class _VideoChatState extends State { opacity: _videoStore.overlayVisible ? 1.0 : 0.0, curve: Curves.ease, duration: const Duration(milliseconds: 200), - child: Container( - decoration: BoxDecoration( - color: Colors.black - .withValues(alpha: settingsStore.overlayOpacity), - ), + child: ColoredBox( + color: Colors.transparent, child: IgnorePointer( ignoring: !_videoStore.overlayVisible, child: videoOverlay, @@ -143,201 +255,434 @@ class _VideoChatState extends State { builder: (context) { if (!_videoStore.settingsStore.showOverlay) return player; - return Stack( - children: [ - player, - overlay, - ], - ); + return Stack(children: [player, overlay]); }, ); final chat = Observer( builder: (context) { - final videoBarVisible = _videoStore.streamInfo != null && - _chatStore.settings.showVideo && - (_videoStore.paused || _videoStore.overlayVisible); + final bool chatOnly = !settingsStore.showVideo; return Stack( children: [ - Chat( + ChatTabs( key: _chatKey, - chatStore: _chatStore, - ), - if (orientation == Orientation.portrait) - AnimatedOpacity( - opacity: videoBarVisible ? 1 : 0, - curve: Curves.ease, - duration: const Duration(milliseconds: 200), - child: IgnorePointer( - ignoring: !videoBarVisible, - child: ColoredBox( - color: Theme.of(context).scaffoldBackgroundColor, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (_videoStore.streamInfo != null) - VideoBar( - streamInfo: _videoStore.streamInfo!, - tappableCategory: false, - ), - const Divider(), - ], - ), - ), - ), - ), - AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeIn, - child: _chatStore.notification != null - ? Align( - alignment: _chatStore.settings.chatNotificationsOnBottom - ? Alignment.bottomCenter - : Alignment.topCenter, - child: FrostyNotification( - message: _chatStore.notification!, - showPasteButton: - _chatStore.notification!.contains('copied'), - onButtonPressed: () async { - // Paste clipboard text into the text controller. - final data = - await Clipboard.getData(Clipboard.kTextPlain); - - if (data != null) { - _chatStore.textController.text = data.text!; - } - - _chatStore.updateNotification(''); - }, - ), - ) + chatTabsStore: _chatTabsStore, + // In chat-only mode, account for the blurred AppBar height + listPadding: chatOnly + ? EdgeInsets.only(top: context.safePaddingTop) : null, ), + Observer( + builder: (_) => AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: _chatStore.notification != null + ? Align( + alignment: Alignment.topCenter, + child: Padding( + padding: EdgeInsets.only( + top: chatOnly ? context.safePaddingTop : 0, + ), + child: FrostyNotification( + message: _chatStore.notification!, + onDismissed: _chatStore.clearNotification, + ), + ), + ) + : null, + ), + ), ], ); }, ); - final videoChat = Scaffold( - body: Observer( - builder: (context) { - if (orientation == Orientation.landscape && - !settingsStore.landscapeForceVerticalChat) { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + final videoChat = Observer( + builder: (context) { + // Build a blurred AppBar when in chat-only mode (no video) + PreferredSizeWidget? chatOnlyBlurredAppBar; + if (!settingsStore.showVideo) { + final streamInfo = _videoStore.streamInfo; - final landscapeChat = AnimatedContainer( - curve: Curves.ease, - duration: const Duration(milliseconds: 200), - width: _chatStore.expandChat - ? MediaQuery.of(context).size.width / 2 - : MediaQuery.of(context).size.width * - _chatStore.settings.chatWidth, - color: _chatStore.settings.fullScreen - ? Colors.black.withValues( - alpha: _chatStore.settings.fullScreenChatOverlayOpacity, - ) - : Theme.of(context).scaffoldBackgroundColor, - child: chat, - ); + chatOnlyBlurredAppBar = AppBar( + centerTitle: false, + elevation: 0, + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + systemOverlayStyle: SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: + context.theme.brightness == Brightness.dark + ? Brightness.light + : Brightness.dark, + ), + title: StreamInfoBar( + streamInfo: streamInfo, + offlineChannelInfo: _videoStore.offlineChannelInfo, + displayName: _chatStore.displayName, + isCompact: true, + isOffline: streamInfo == null, + isInSharedChatMode: _chatStore.isInSharedChatMode, + showTextShadows: false, + ), + flexibleSpace: BlurredContainer( + gradientDirection: GradientDirection.up, + child: const SizedBox.expand(), + ), + ); + } - final overlayChat = Visibility( - visible: settingsStore.fullScreenChatOverlay, - maintainState: true, - child: Theme( - data: FrostyThemes( - colorSchemeSeed: Color(settingsStore.accentColor), - ).dark, - child: DefaultTextStyle( - style: DefaultTextStyle.of(context).style.copyWith( + return Scaffold( + extendBody: true, + extendBodyBehindAppBar: true, + appBar: chatOnlyBlurredAppBar, + body: Observer( + builder: (context) { + if (context.isLandscape && + !settingsStore.landscapeForceVerticalChat) { + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.immersiveSticky, + ); + + final landscapeChat = AnimatedContainer( + curve: Curves.ease, + duration: _isDividerDragging + ? Duration.zero + : const Duration(milliseconds: 200), + width: _chatStore.expandChat + ? context.screenWidth / 2 + : context.screenWidth * settingsStore.chatWidth, + color: settingsStore.fullScreen + ? Colors.black.withValues( + alpha: settingsStore.fullScreenChatOverlayOpacity, + ) + : context.scaffoldColor, + child: chat, + ); + + final overlayChat = Visibility( + visible: settingsStore.fullScreenChatOverlay, + maintainState: true, + child: Theme( + data: FrostyThemes( + colorSchemeSeed: Color(settingsStore.accentColor), + ).dark, + child: DefaultTextStyle( + style: context.defaultTextStyle.copyWith( color: context .watch() .dark .colorScheme .onSurface, + shadows: settingsStore.fullScreen && context.isLandscape + ? const [ + Shadow(blurRadius: 8), + Shadow(blurRadius: 4, offset: Offset(1, 1)), + ] + : null, ), - child: landscapeChat, - ), - ), - ); + child: landscapeChat, + ), + ), + ); - return ColoredBox( - color: settingsStore.showVideo - ? Colors.black - : Theme.of(context).scaffoldBackgroundColor, - child: SafeArea( - bottom: false, - left: (settingsStore.landscapeCutout == - LandscapeCutoutType.both || - settingsStore.landscapeCutout == - LandscapeCutoutType.left) - ? false - : true, - right: (settingsStore.landscapeCutout == - LandscapeCutoutType.both || - settingsStore.landscapeCutout == - LandscapeCutoutType.right) - ? false - : true, - child: settingsStore.showVideo - ? settingsStore.fullScreen - ? Stack( - children: [ - player, - if (settingsStore.showOverlay) - Row( - children: settingsStore.landscapeChatLeftSide - ? [ - overlayChat, - Expanded(child: overlay), - ] - : [ - Expanded(child: overlay), - overlayChat, - ], + return ColoredBox( + color: settingsStore.showVideo + ? Colors.black + : context.scaffoldColor, + child: settingsStore.showVideo + ? settingsStore.fullScreen + ? Stack( + children: [ + player, + if (settingsStore.showOverlay) + LayoutBuilder( + builder: (context, constraints) { + final totalWidth = constraints.maxWidth; + final chatWidth = _chatStore.expandChat + ? 0.5 + : settingsStore.chatWidth; + + final draggableDivider = Observer( + builder: (_) => DraggableDivider( + currentWidth: chatWidth, + maxWidth: 0.6, + isResizableOnLeft: settingsStore + .landscapeChatLeftSide, + showHandle: + _videoStore.overlayVisible && + settingsStore + .fullScreenChatOverlay, + onDragStart: () { + setState(() { + _isDividerDragging = true; + }); + }, + onDrag: (newWidth) { + if (!_chatStore.expandChat) { + settingsStore.chatWidth = + newWidth; + } + }, + onDragEnd: () { + setState(() { + _isDividerDragging = false; + }); + }, + ), + ); + + return Stack( + children: [ + Row( + children: + settingsStore + .landscapeChatLeftSide + ? [ + overlayChat, + Expanded(child: overlay), + ] + : [ + Expanded(child: overlay), + overlayChat, + ], + ), + Positioned( + top: 0, + bottom: 0, + left: + settingsStore + .landscapeChatLeftSide + ? (totalWidth * chatWidth) - + 12 + : null, + right: + !settingsStore + .landscapeChatLeftSide + ? (totalWidth * chatWidth) - + 12 + : null, + child: draggableDivider, + ), + ], + ); + }, + ), + ], + ) + : SafeArea( + left: + settingsStore.landscapeCutout != + LandscapeCutoutType.left && + settingsStore.landscapeCutout != + LandscapeCutoutType.both, + right: + settingsStore.landscapeCutout != + LandscapeCutoutType.right && + settingsStore.landscapeCutout != + LandscapeCutoutType.both, + child: LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.maxWidth; + final chatWidth = _chatStore.expandChat + ? 0.5 + : settingsStore.chatWidth; + + // Create the landscape chat container with proper styling + final chatContainer = AnimatedContainer( + curve: Curves.ease, + duration: _isDividerDragging + ? Duration.zero + : const Duration(milliseconds: 200), + width: availableWidth * chatWidth, + color: settingsStore.fullScreen + ? Colors.black.withValues( + alpha: settingsStore + .fullScreenChatOverlayOpacity, + ) + : context.scaffoldColor, + child: chat, + ); + + final draggableDivider = Observer( + builder: (_) => DraggableDivider( + currentWidth: chatWidth, + maxWidth: 0.6, + isResizableOnLeft: + settingsStore.landscapeChatLeftSide, + showHandle: _videoStore.overlayVisible, + onDragStart: () { + setState(() { + _isDividerDragging = true; + }); + }, + onDrag: (newWidth) { + if (!_chatStore.expandChat) { + settingsStore.chatWidth = newWidth; + } + }, + onDragEnd: () { + setState(() { + _isDividerDragging = false; + }); + }, + ), + ); + + return Stack( + children: [ + Row( + children: + settingsStore + .landscapeChatLeftSide + ? [ + chatContainer, + Expanded(child: video), + ] + : [ + Expanded(child: video), + chatContainer, + ], + ), + Positioned( + top: 0, + bottom: 0, + left: + settingsStore + .landscapeChatLeftSide + ? (availableWidth * chatWidth) - + 12 + : null, + right: + !settingsStore + .landscapeChatLeftSide + ? (availableWidth * chatWidth) - + 12 + : null, + child: draggableDivider, + ), + ], + ); + }, ), - ], - ) - : Row( - children: settingsStore.landscapeChatLeftSide - ? [ - landscapeChat, - const VerticalDivider(), - Expanded(child: video), - ] - : [ - Expanded(child: video), - const VerticalDivider(), - landscapeChat, - ], - ) - : Column( - children: [appBar, Expanded(child: chat)], - ), - ), - ); - } + ) + : SafeArea(child: chat), + ); + } - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); - return SafeArea( - child: Column( - children: [ - if (!settingsStore.showVideo) - appBar - else ...[ - AspectRatio(aspectRatio: 16 / 9, child: video), - const Divider(), - ], - Expanded(child: chat), - ], - ), - ); - }, - ), + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.manual, + overlays: SystemUiOverlay.values, + ); + return SafeArea( + // Keep chat-only scrolling under the blurred app bar, but + // ensure video does not bleed into the system status bar. + top: settingsStore.showVideo, + bottom: false, + child: Stack( + children: [ + Column( + children: [ + if (settingsStore.showVideo) ...[ + AspectRatio( + aspectRatio: 16 / 9, + child: Container(), // Placeholder for video space + ), + ], + Expanded(child: chat), + ], + ), + if (settingsStore.showVideo) + Positioned( + top: 0, + left: 0, + right: 0, + child: AnimatedBuilder( + animation: Listenable.merge([ + _animationController, + _springBackAnimation, + ]), + builder: (context, child) { + // Calculate current drag distance from either manual drag or animation + final currentDragDistance = _isPipDragging + ? _pipDragDistance + : (_springBackAnimation.value); + + // Simple scale effect for visual feedback + final scaleFactor = + 1.0 - + (currentDragDistance / + _pipMaxDragDistance * + 0.1); + + return Transform.translate( + offset: Offset(0, currentDragDistance), + child: Transform.scale( + scale: scaleFactor.clamp(0.9, 1.0), + child: Stack( + children: [ + GestureDetector( + onPanStart: _handlePipDragStart, + onPanUpdate: _handlePipDragUpdate, + onPanEnd: _handlePipDragEnd, + onPanCancel: _handlePipDragCancel, + child: AspectRatio( + aspectRatio: 16 / 9, + child: video, + ), + ), + // Simple text overlay that follows the video with smooth opacity animation + if (!_videoStore.isInPipMode) + Positioned.fill( + child: IgnorePointer( + ignoring: + !(_isPipDragging && + _pipDragDistance > 0), + child: AnimatedOpacity( + opacity: + (_isPipDragging && + _pipDragDistance > 0) + ? 1.0 + : 0.0, + duration: const Duration( + milliseconds: 150, + ), + curve: Curves.easeOut, + child: Container( + color: Colors.black.withValues( + alpha: 0.4, + ), + child: const Center( + child: Text( + 'Swipe down to enter picture-in-picture', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ); + }, + ), + ); + }, ); // If on Android, use PiPSwitcher to enable PiP functionality. @@ -355,10 +700,15 @@ class _VideoChatState extends State { @override void dispose() { - _chatStore.dispose(); + // Remove observer for app lifecycle events + WidgetsBinding.instance.removeObserver(this); + + _chatTabsStore.dispose(); _videoStore.dispose(); + _animationController.dispose(); + SystemChrome.setEnabledSystemUIMode( SystemUiMode.manual, overlays: SystemUiOverlay.values, diff --git a/lib/screens/channel/chat/chat.dart b/lib/screens/channel/chat/chat.dart index 42cb1dc0..165f6274 100644 --- a/lib/screens/channel/chat/chat.dart +++ b/lib/screens/channel/chat/chat.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_panel.dart'; @@ -7,12 +5,24 @@ import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart' import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_bottom_bar.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_message.dart'; -import 'package:frosty/widgets/page_view.dart'; +import 'package:frosty/utils/context_extensions.dart'; +import 'package:frosty/widgets/frosty_page_view.dart'; +import 'package:frosty/widgets/frosty_scrollbar.dart'; class Chat extends StatelessWidget { final ChatStore chatStore; + final EdgeInsetsGeometry? listPadding; - const Chat({super.key, required this.chatStore}); + /// Callback to add a new chat tab. + /// Passes this to ChatBottomBar for the ChatDetails menu. + final VoidCallback onAddChat; + + const Chat({ + super.key, + required this.chatStore, + this.listPadding, + required this.onAddChat, + }); @override Widget build(BuildContext context) { @@ -26,7 +36,7 @@ class Chat extends StatelessWidget { if (chatStore.assetsStore.showEmoteMenu) { chatStore.assetsStore.showEmoteMenu = false; } else if (chatStore.textFieldFocusNode.hasFocus) { - chatStore.textFieldFocusNode.unfocus(); + chatStore.unfocusInput(); } }, child: Stack( @@ -34,26 +44,45 @@ class Chat extends StatelessWidget { children: [ MediaQuery( data: MediaQuery.of(context).copyWith( - textScaler: - TextScaler.linear(chatStore.settings.messageScale), + textScaler: chatStore.settings.messageScale.textScaler, ), child: DefaultTextStyle( - style: DefaultTextStyle.of(context) - .style - .copyWith(fontSize: chatStore.settings.fontSize), - child: Scrollbar( + style: context.defaultTextStyle.copyWith( + fontSize: chatStore.settings.fontSize, + ), + child: FrostyScrollbar( controller: chatStore.scrollController, + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top, + bottom: + 68 + + (chatStore.assetsStore.showEmoteMenu + ? 0 + : MediaQuery.of(context).padding.bottom), + ), child: Observer( builder: (context) { return ListView.builder( reverse: true, - padding: EdgeInsets.zero, + padding: (listPadding ?? EdgeInsets.zero).add( + EdgeInsets.only( + bottom: + 68 + + (chatStore.assetsStore.showEmoteMenu + ? 0 + : MediaQuery.of( + context, + ).padding.bottom), + ), + ), addAutomaticKeepAlives: false, controller: chatStore.scrollController, itemCount: chatStore.renderMessages.length, itemBuilder: (context, index) => ChatMessage( - ircMessage: chatStore.renderMessages[ - chatStore.renderMessages.length - + ircMessage: + chatStore.renderMessages[chatStore + .renderMessages + .length - 1 - index], chatStore: chatStore, @@ -64,8 +93,40 @@ class Chat extends StatelessWidget { ), ), ), - Padding( - padding: const EdgeInsets.all(4), + // Prevents accidental chat scrolling when swiping down from the top edge + // to access system UI (Notification Center/Control Center) in landscape mode. + if (context.isLandscape) + Positioned( + top: 0, + left: 0, + right: 0, + height: 24, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onVerticalDragStart: (_) {}, + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: ChatBottomBar( + chatStore: chatStore, + onAddChat: onAddChat, + ), + ), + AnimatedPadding( + duration: const Duration(milliseconds: 200), + padding: EdgeInsets.only( + left: 4, + top: 4, + right: 4, + bottom: + 68 + + (chatStore.assetsStore.showEmoteMenu + ? 0 + : MediaQuery.of(context).padding.bottom), + ), child: Observer( builder: (_) => AnimatedSwitcher( duration: const Duration(milliseconds: 200), @@ -75,8 +136,9 @@ class Chat extends StatelessWidget { ? null : ElevatedButton.icon( onPressed: chatStore.resumeScroll, - icon: - const Icon(Icons.arrow_downward_rounded), + icon: const Icon( + Icons.arrow_downward_rounded, + ), label: Text( chatStore.messageBuffer.isNotEmpty ? '${chatStore.messageBuffer.length} new ${chatStore.messageBuffer.length == 1 ? 'message' : 'messages'}' @@ -95,37 +157,19 @@ class Chat extends StatelessWidget { ), ), ), - if (chatStore.settings.showBottomBar) - ChatBottomBar(chatStore: chatStore), - PopScope( - canPop: Platform.isIOS, - onPopInvokedWithResult: (didPop, _) { - if (didPop) return; - - // If pressing the back button on Android while the emote menu - // is open, close it instead of going back to the streams list. - if (chatStore.assetsStore.showEmoteMenu) { - chatStore.assetsStore.showEmoteMenu = false; - } else { - Navigator.of(context).pop(); - } - }, - child: AnimatedContainer( - curve: Curves.ease, - duration: const Duration(milliseconds: 200), - height: chatStore.assetsStore.showEmoteMenu - ? MediaQuery.of(context).size.height / - (MediaQuery.of(context).orientation == - Orientation.portrait - ? 3 - : 2) - : 0, - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 100), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeIn, - child: chatStore.assetsStore.showEmoteMenu - ? Column( + AnimatedContainer( + curve: Curves.ease, + duration: const Duration(milliseconds: 200), + height: chatStore.assetsStore.showEmoteMenu + ? context.screenHeight / (context.isPortrait ? 3 : 2) + : 0, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 100), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: chatStore.assetsStore.showEmoteMenu + ? ClipRect( + child: Column( children: [ const Divider(), Expanded( @@ -139,14 +183,13 @@ class Chat extends StatelessWidget { if (chatStore.settings.showFFZEmotes) 'FFZ', ], children: [ - RecentEmotesPanel( - chatStore: chatStore, - ), + RecentEmotesPanel(chatStore: chatStore), if (chatStore.settings.showTwitchEmotes) EmoteMenuPanel( chatStore: chatStore, twitchEmotes: chatStore - .assetsStore.userEmoteSectionToEmotes, + .assetsStore + .userEmoteSectionToEmotes, ), ...[ if (chatStore.settings.show7TVEmotes) @@ -165,9 +208,9 @@ class Chat extends StatelessWidget { ), ), ], - ) - : null, - ), + ), + ) + : null, ), ), ], diff --git a/lib/screens/channel/chat/details/chat_details.dart b/lib/screens/channel/chat/details/chat_details.dart index e50ee31a..89c225d4 100644 --- a/lib/screens/channel/chat/details/chat_details.dart +++ b/lib/screens/channel/chat/details/chat_details.dart @@ -1,12 +1,20 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/constants.dart'; import 'package:frosty/main.dart'; +import 'package:frosty/models/irc.dart'; import 'package:frosty/screens/channel/chat/details/chat_details_store.dart'; import 'package:frosty/screens/channel/chat/details/chat_modes.dart'; import 'package:frosty/screens/channel/chat/details/chat_users_list.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/settings/settings.dart'; +import 'package:frosty/utils.dart'; +import 'package:frosty/utils/modal_bottom_sheet.dart'; import 'package:frosty/widgets/animated_scroll_border.dart'; +import 'package:frosty/widgets/frosty_scrollbar.dart'; import 'package:frosty/widgets/section_header.dart'; import 'package:intl/intl.dart'; @@ -15,11 +23,16 @@ class ChatDetails extends StatefulWidget { final ChatStore chatStore; final String userLogin; + /// Callback to add a new chat tab. + /// Shows an "Add chat" option in the menu. + final VoidCallback onAddChat; + const ChatDetails({ super.key, required this.chatDetailsStore, required this.chatStore, required this.userLogin, + required this.onAddChat, }); @override @@ -29,6 +42,9 @@ class ChatDetails extends StatefulWidget { class _ChatDetailsState extends State { late final _scrollController = ScrollController(); + bool _isRefreshingAssets = false; + bool _showRefreshSuccess = false; + String formatDuration(Duration duration) { if (duration.inMinutes < 60) { return '${duration.inMinutes} ${Intl.plural(duration.inMinutes, one: 'minute', other: 'minutes')}'; @@ -43,11 +59,11 @@ class _ChatDetailsState extends State { final seconds = duration.inSeconds.remainder(60); if (hours > 0) { - return '${hours}h ${minutes}m left'; + return '${hours}h ${minutes}m'; } else if (minutes > 0) { - return '${minutes}m ${seconds}s left'; + return '${minutes}m ${seconds}s'; } else { - return '${seconds}s left'; + return '${seconds}s'; } } @@ -71,7 +87,7 @@ class _ChatDetailsState extends State { Duration(hours: 12), ]; - return showModalBottomSheet( + return showModalBottomSheetWithProperFocus( context: context, builder: (context) => Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -91,10 +107,24 @@ class _ChatDetailsState extends State { builder: (context) { return ListTile( leading: const Icon(Icons.close_rounded), - title: Text( - 'Turn off (${formatTimeLeft(widget.chatStore.timeRemaining)})', - style: const TextStyle( - fontFeatures: [FontFeature.tabularFigures()], + title: Text.rich( + TextSpan( + text: 'Cancel ', + children: [ + TextSpan( + text: formatTimeLeft( + widget.chatStore.timeRemaining, + ), + style: TextStyle( + color: Theme.of(context) + .textTheme + .bodyMedium + ?.color + ?.withValues(alpha: 0.6), + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ], ), ), onTap: () { @@ -128,127 +158,273 @@ class _ChatDetailsState extends State { ); } - Future _showClearDialog(BuildContext context) { - return showDialog( - context: context, - builder: (context) => AlertDialog.adaptive( - title: const Text('Clear recent emotes'), - content: - const Text('Are you sure you want to clear your recent emotes?'), - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - widget.chatStore.assetsStore.recentEmotes.clear(); + /// Converts a hex color string to a color name if it matches one of our predefined chat colors. + /// Returns null if no match is found. + String? _hexToColorName(String hexColor) { + // Remove # if present + final cleanHex = hexColor.startsWith('#') + ? hexColor.substring(1) + : hexColor; - Navigator.pop(context); - }, - child: const Text('Yes'), - ), - ], + // Convert to uppercase for comparison + final upperHex = cleanHex.toUpperCase(); + + // Find matching color name + for (final entry in chatColorValues.entries) { + final colorValue = entry.value.toARGB32().toRadixString(16).toUpperCase(); + // Remove alpha channel (first 2 characters) for comparison + if (colorValue.length >= 6) { + final colorHex = colorValue.substring(2); + if (colorHex == upperHex) { + return entry.key; + } + } + } + + return null; + } + + Future _showChatColorPicker(BuildContext context) async { + final selectedColor = await _getCurrentUserColor(); + if (!context.mounted) return; + + showModalBottomSheetWithProperFocus( + context: context, + builder: (context) => SizedBox( + height: MediaQuery.of(context).size.height * 0.5, + child: _ChatColorPickerModal( + initialColor: selectedColor, + scrollController: _scrollController, + onColorSelected: _handleColorUpdate, + chatStore: widget.chatStore, + ), ), ); } + Future _getCurrentUserColor() async { + try { + final currentColorHex = await widget.chatStore.twitchApi.getUserChatColor( + userId: widget.chatStore.auth.user.details!.id, + ); + + if (currentColorHex.isNotEmpty) { + return _hexToColorName(currentColorHex); + } + } catch (e) { + // Ignore errors when fetching current color + } + return null; + } + + Future _handleColorUpdate(BuildContext context, String color) async { + final success = await widget.chatStore.twitchApi.updateUserChatColor( + userId: widget.chatStore.auth.user.details!.id, + color: color, + ); + + if (!context.mounted) return; + Navigator.of(context).pop(); + + final message = success + ? 'Chat color updated successfully!' + : 'Failed to update chat color. Please try again.'; + _showMessage(context, message); + } + + void _showMessage(BuildContext context, String message) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + bool _hasActiveModes() { + final roomState = widget.chatDetailsStore.roomState; + return roomState.subMode != '0' || + roomState.followersOnly != '-1' || + roomState.emoteOnly != '0' || + roomState.slowMode != '0' || + roomState.r9k != '0'; + } + + Widget _buildRefreshTrailingWidget() { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: _isRefreshingAssets + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ) + : _showRefreshSuccess + ? Icon( + Icons.check_rounded, + color: Theme.of(context).colorScheme.primary, + key: const ValueKey('success'), + ) + : const SizedBox.shrink(), + ); + } + + Future _handleRefreshAssets() async { + setState(() => _isRefreshingAssets = true); + + try { + await widget.chatStore.getAssets(); + if (mounted) { + HapticFeedback.lightImpact(); + setState(() { + _isRefreshingAssets = false; + _showRefreshSuccess = true; + }); + + // Hide success after 2 seconds + Timer(const Duration(seconds: 2), () { + if (mounted) { + setState(() => _showRefreshSuccess = false); + } + }); + } + } catch (e) { + if (mounted) { + setState(() => _isRefreshingAssets = false); + } + } + } + @override Widget build(BuildContext context) { - final children = [ - const SectionHeader( - 'Chat modes', - padding: EdgeInsets.fromLTRB(16, 0, 16, 8), - isFirst: true, - ), - ListTile( - title: ChatModes(roomState: widget.chatDetailsStore.roomState), - ), - const SectionHeader( - 'More', - ), - ListTile( - leading: const Icon(Icons.people_outline), - title: const Text('Chatters'), - onTap: () => showModalBottomSheet( - isScrollControlled: true, - context: context, - builder: (context) => GestureDetector( - onTap: FocusScope.of(context).unfocus, - child: ChattersList( - chatDetailsStore: widget.chatDetailsStore, - chatStore: widget.chatStore, - userLogin: widget.userLogin, - ), - ), - ), - ), - Observer( - builder: (context) { - return ListTile( - leading: const Icon(Icons.timer_outlined), - title: Text( - 'Sleep timer ${widget.chatStore.timeRemaining.inSeconds > 0 ? 'on (${formatTimeLeft(widget.chatStore.timeRemaining)})' : ''}', - style: const TextStyle( - fontFeatures: [FontFeature.tabularFigures()], + return Observer( + builder: (context) { + final hasActiveModes = _hasActiveModes(); + + final children = [ + if (hasActiveModes) + ListTile( + title: SizedBox( + height: 48, + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + Observer( + builder: (context) => ChatModes( + roomState: widget.chatDetailsStore.roomState, + ), + ), + ], + ), ), ), - onTap: () => _showSleepTimer(context), - ); - }, - ), - ListTile( - leading: const Icon(Icons.delete_outline_rounded), - title: const Text('Clear recent emotes'), - onTap: () => _showClearDialog(context), - ), - ListTile( - leading: const Icon(Icons.refresh_rounded), - title: const Text('Reconnect to chat'), - onTap: () { - widget.chatStore.updateNotification('Reconnecting to chat...'); - - widget.chatStore.connectToChat(); - }, - ), - ListTile( - leading: const Icon(Icons.refresh_rounded), - title: const Text('Refresh badges and emotes'), - onTap: () async { - await widget.chatStore.getAssets(); - - widget.chatStore.updateNotification('Badges and emotes refreshed'); - }, - ), - ListTile( - leading: const Icon(Icons.chat_outlined), - title: Observer( - builder: (context) { - return Text( - '${widget.chatStore.settings.showVideo ? 'Enter' : 'Exit'} chat-only mode', - ); - }, - ), - onTap: () => widget.chatStore.settings.showVideo = - !widget.chatStore.settings.showVideo, - ), - ListTile( - leading: const Icon(Icons.settings_outlined), - title: const Text('Settings'), - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - Settings(settingsStore: widget.chatStore.settings), + Observer( + builder: (context) { + final showVideo = widget.chatStore.settings.showVideo; + final label = showVideo ? 'Chat only' : 'Show video'; + return ListTile( + leading: Icon( + showVideo ? Icons.chat_rounded : Icons.tv_rounded, + ), + title: Text(label), + onTap: () { + widget.chatStore.settings.showVideo = !showVideo; + Navigator.of(context).pop(); + }, + ); + }, ), - ), - ), - ]; + ListTile( + leading: const Icon(Icons.sync_rounded), + title: const Text('Refresh emotes and badges'), + trailing: _buildRefreshTrailingWidget(), + enabled: !_isRefreshingAssets, + onTap: _isRefreshingAssets ? null : _handleRefreshAssets, + ), + ListTile( + leading: const Icon(Icons.wifi_off_rounded), + title: const Text('Reconnect'), + onTap: () { + Navigator.of(context).pop(); + widget.chatStore.updateNotification('Reconnecting to chat...'); + widget.chatStore.connectToChat(); + }, + ), + if (widget.chatStore.auth.isLoggedIn) + ListTile( + leading: const Icon(Icons.palette_rounded), + title: const Text('Username color'), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showChatColorPicker(context), + ), + ListTile( + leading: const Icon(Icons.people_rounded), + title: const Text('Chatters'), + trailing: const Icon(Icons.chevron_right), + onTap: () => showModalBottomSheetWithProperFocus( + isScrollControlled: true, + context: context, + builder: (context) => GestureDetector( + onTap: FocusScope.of(context).unfocus, + child: ChattersList( + chatDetailsStore: widget.chatDetailsStore, + chatStore: widget.chatStore, + userLogin: widget.userLogin, + ), + ), + ), + ), + ListTile( + leading: const Icon(Icons.add_comment_rounded), + title: const Text('Add chat'), + trailing: const Icon(Icons.chevron_right), + onTap: widget.onAddChat, + ), + Observer( + builder: (context) { + final hasTimer = widget.chatStore.timeRemaining.inSeconds > 0; + final label = hasTimer + ? formatTimeLeft(widget.chatStore.timeRemaining) + : 'Sleep timer'; + return ListTile( + leading: Icon( + hasTimer ? Icons.timer_rounded : Icons.timer_rounded, + color: hasTimer + ? Theme.of(context).colorScheme.primary + : null, + ), + title: Text( + label, + style: hasTimer + ? TextStyle( + fontFeatures: const [FontFeature.tabularFigures()], + color: Theme.of(context).colorScheme.primary, + ) + : null, + ), + trailing: hasTimer + ? Icon( + Icons.chevron_right, + color: Theme.of(context).colorScheme.primary, + ) + : const Icon(Icons.chevron_right), + onTap: () => _showSleepTimer(context), + ); + }, + ), + ListTile( + leading: const Icon(Icons.settings_rounded), + title: const Text('Settings'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + Settings(settingsStore: widget.chatStore.settings), + ), + ), + ), + ]; - return ListView( - shrinkWrap: true, - primary: false, - children: children, + return ListView(shrinkWrap: true, primary: false, children: children); + }, ); } @@ -258,3 +434,187 @@ class _ChatDetailsState extends State { super.dispose(); } } + +class _ChatColorPickerModal extends StatefulWidget { + const _ChatColorPickerModal({ + required this.initialColor, + required this.scrollController, + required this.onColorSelected, + required this.chatStore, + }); + + final String? initialColor; + final ScrollController scrollController; + final Future Function(BuildContext context, String color) + onColorSelected; + final ChatStore chatStore; + + @override + State<_ChatColorPickerModal> createState() => _ChatColorPickerModalState(); +} + +class _ChatColorPickerModalState extends State<_ChatColorPickerModal> { + String? selectedColor; + + @override + void initState() { + super.initState(); + selectedColor = widget.initialColor; + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(), + _buildPreview(), + AnimatedScrollBorder(scrollController: widget.scrollController), + _buildColorList(), + _buildBottomBar(), + ], + ); + } + + Widget _buildHeader() { + return const SectionHeader('Username color', isFirst: true); + } + + Widget _buildPreview() { + if (selectedColor == null) return const SizedBox.shrink(); + + // Create a realistic preview using the current user's chat state and badges + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + child: _buildRealisticChatPreview(), + ); + } + + Widget _buildRealisticChatPreview() { + final userDetails = widget.chatStore.auth.user.details!; + final userState = widget.chatStore.userState; + + // Create a mock IRC message with the user's current state + final mockTags = { + 'display-name': userDetails.displayName, + 'color': + '#${chatColorValues[selectedColor!]!.toARGB32().toRadixString(16).substring(2)}', + 'user-id': userDetails.id, + 'mod': userState.mod ? '1' : '0', + 'subscriber': userState.subscriber ? '1' : '0', + 'id': 'preview-message', + 'tmi-sent-ts': DateTime.now().millisecondsSinceEpoch.toString(), + }; + + // Copy any badges from the current user state + if (userState.raw != null) { + final userStateMessage = IRCMessage.fromString(userState.raw!); + if (userStateMessage.tags['badges'] != null) { + mockTags['badges'] = userStateMessage.tags['badges']!; + } + if (userStateMessage.tags['badge-info'] != null) { + mockTags['badge-info'] = userStateMessage.tags['badge-info']!; + } + } + + final mockMessage = IRCMessage( + raw: '', + command: Command.privateMessage, + tags: mockTags, + user: userDetails.login, + message: 'How it will look in chat', + split: ['How', 'it', 'will', 'look', 'in', 'chat'], + action: false, + mention: false, + ); + + return Text.rich( + TextSpan( + children: mockMessage.generateSpan( + context, + assetsStore: widget.chatStore.assetsStore, + emoteScale: widget.chatStore.settings.emoteScale, + badgeScale: widget.chatStore.settings.badgeScale, + launchExternal: false, // Disable launching for preview + style: DefaultTextStyle.of(context).style, + ), + ), + ); + } + + Widget _buildColorList() { + return Expanded( + child: FrostyScrollbar( + controller: widget.scrollController, + child: ListView( + padding: EdgeInsets.zero, + controller: widget.scrollController, + children: chatColorNames + .map((colorName) => _buildColorTile(colorName)) + .toList(), + ), + ), + ); + } + + Widget _buildColorTile(String colorName) { + final isSelected = selectedColor == colorName; + final displayName = colorName + .replaceAll('_', ' ') + .split(' ') + .map((word) => word[0].toUpperCase() + word.substring(1)) + .join(' '); + + // Apply the same color adjustment for consistent preview + final rawColor = chatColorValues[colorName]!; + final adjustedColor = adjustChatNameColor(context, rawColor); + + return ListTile( + leading: Container( + width: 24, + height: 24, + decoration: BoxDecoration(color: adjustedColor, shape: BoxShape.circle), + ), + title: Text(displayName), + trailing: isSelected + ? Icon(Icons.check, color: Theme.of(context).colorScheme.primary) + : null, + onTap: () => setState(() => selectedColor = colorName), + ); + } + + Widget _buildBottomBar() { + return Column( + children: [ + AnimatedScrollBorder( + scrollController: widget.scrollController, + position: ScrollBorderPosition.bottom, + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Expanded( + child: TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton( + onPressed: selectedColor == null + ? null + : () => widget.onColorSelected(context, selectedColor!), + child: const Text('Save'), + ), + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/screens/channel/chat/details/chat_details_store.g.dart b/lib/screens/channel/chat/details/chat_details_store.g.dart index 6cd8e900..cc6ee138 100644 --- a/lib/screens/channel/chat/details/chat_details_store.g.dart +++ b/lib/screens/channel/chat/details/chat_details_store.g.dart @@ -12,13 +12,16 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store { Computed>? _$filteredUsersComputed; @override - Iterable get filteredUsers => (_$filteredUsersComputed ??= - Computed>(() => super.filteredUsers, - name: 'ChatDetailsStoreBase.filteredUsers')) - .value; + Iterable get filteredUsers => + (_$filteredUsersComputed ??= Computed>( + () => super.filteredUsers, + name: 'ChatDetailsStoreBase.filteredUsers', + )).value; - late final _$roomStateAtom = - Atom(name: 'ChatDetailsStoreBase.roomState', context: context); + late final _$roomStateAtom = Atom( + name: 'ChatDetailsStoreBase.roomState', + context: context, + ); @override ROOMSTATE get roomState { @@ -33,8 +36,10 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store { }); } - late final _$showJumpButtonAtom = - Atom(name: 'ChatDetailsStoreBase.showJumpButton', context: context); + late final _$showJumpButtonAtom = Atom( + name: 'ChatDetailsStoreBase.showJumpButton', + context: context, + ); @override bool get showJumpButton { @@ -49,8 +54,10 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store { }); } - late final _$_filterTextAtom = - Atom(name: 'ChatDetailsStoreBase._filterText', context: context); + late final _$_filterTextAtom = Atom( + name: 'ChatDetailsStoreBase._filterText', + context: context, + ); String get filterText { _$_filterTextAtom.reportRead(); diff --git a/lib/screens/channel/chat/details/chat_modes.dart b/lib/screens/channel/chat/details/chat_modes.dart index bf62bfe5..9e22f939 100644 --- a/lib/screens/channel/chat/details/chat_modes.dart +++ b/lib/screens/channel/chat/details/chat_modes.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/models/irc.dart'; class ChatModes extends StatelessWidget { @@ -10,81 +11,172 @@ class ChatModes extends StatelessWidget { @override Widget build(BuildContext context) { - final disabledColor = - Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5); + return Observer( + builder: (context) { + final activeModes = >[]; - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Tooltip( - preferBelow: false, - triggerMode: TooltipTriggerMode.tap, - showDuration: const Duration(seconds: 3), - message: - 'Emote-only mode ${roomState.emoteOnly != '0' ? 'on' : 'off'}', - child: Icon( - roomState.emoteOnly != '0' - ? Icons.emoji_emotions_rounded - : Icons.emoji_emotions_outlined, - color: roomState.emoteOnly != '0' ? Colors.yellow : disabledColor, - ), - ), - Tooltip( - preferBelow: false, - triggerMode: TooltipTriggerMode.tap, - showDuration: const Duration(seconds: 3), - message: roomState.followersOnly == '-1' - ? 'Followers-only mode off' - : roomState.followersOnly == '0' - ? 'Followers-only mode on' - : 'Followers-only mode on (${roomState.followersOnly} ${pluralize('minute', roomState.followersOnly)})', - child: Icon( - roomState.followersOnly != '-1' - ? Icons.favorite_rounded - : Icons.favorite_outline_rounded, - color: roomState.followersOnly != '-1' ? Colors.red : disabledColor, - ), - ), - Tooltip( - preferBelow: false, - triggerMode: TooltipTriggerMode.tap, - showDuration: const Duration(seconds: 3), - message: - 'R9K (unique-chat) mode ${roomState.r9k != '0' ? 'on' : 'off'}', - child: Text( - 'R9K', - style: TextStyle( - color: roomState.r9k != '0' ? Colors.purple : disabledColor, - fontWeight: FontWeight.w500, + // Collect active modes with their labels for sorting + if (roomState.emoteOnly != '0') { + activeModes.add( + MapEntry( + 'Emote only', + _buildModeChip( + context: context, + icon: Icons.emoji_emotions_outlined, + activeIcon: Icons.emoji_emotions_rounded, + label: 'Emote only', + activeLabel: 'Emote only', + isActive: true, + activeColor: const Color(0xFFFFB74D), // Slightly lighter orange + ), ), - ), - ), - Tooltip( - preferBelow: false, - triggerMode: TooltipTriggerMode.tap, - showDuration: const Duration(seconds: 3), - message: - 'Slow mode ${roomState.slowMode != '0' ? 'on (${roomState.slowMode} ${pluralize('second', roomState.slowMode)})' : 'off'}', - child: Icon( - roomState.slowMode != '0' - ? Icons.hourglass_top_rounded - : Icons.hourglass_empty_rounded, - color: roomState.slowMode != '0' ? Colors.blue : disabledColor, - ), - ), - Tooltip( - preferBelow: false, - triggerMode: TooltipTriggerMode.tap, - showDuration: const Duration(seconds: 3), - message: 'Subs-only mode ${roomState.subMode != '0' ? 'on' : 'off'}', - child: Icon( - roomState.subMode != '0' - ? Icons.monetization_on_rounded - : Icons.monetization_on_outlined, - color: roomState.subMode != '0' ? Colors.green : disabledColor, - ), - ), - ], + ); + } + + if (roomState.followersOnly != '-1') { + activeModes.add( + MapEntry( + 'Follower only', + _buildModeChip( + context: context, + icon: Icons.favorite_outline_rounded, + activeIcon: Icons.favorite_rounded, + label: 'Follower only', + activeLabel: 'Follower only', + isActive: true, + activeColor: const Color(0xFFF44336), // Bright red + duration: _getFollowersDuration(), + ), + ), + ); + } + + if (roomState.slowMode != '0') { + activeModes.add( + MapEntry( + 'Slow mode', + _buildModeChip( + context: context, + icon: Icons.hourglass_empty_rounded, + activeIcon: Icons.hourglass_top_rounded, + label: 'Slow mode', + activeLabel: 'Slow mode', + isActive: true, + activeColor: const Color(0xFF2196F3), // Bright blue + duration: _getSlowModeDuration(), + ), + ), + ); + } + + if (roomState.subMode != '0') { + activeModes.add( + MapEntry( + 'Sub only', + _buildModeChip( + context: context, + icon: Icons.monetization_on_outlined, + activeIcon: Icons.monetization_on_rounded, + label: 'Sub only', + activeLabel: 'Sub only', + isActive: true, + activeColor: const Color(0xFF4CAF50), // Bright green + ), + ), + ); + } + + if (roomState.r9k != '0') { + activeModes.add( + MapEntry( + 'Unique mode', + _buildModeChip( + context: context, + icon: Icons.quiz_outlined, + activeIcon: Icons.quiz_rounded, + label: 'Unique mode', + activeLabel: 'Unique mode', + isActive: true, + activeColor: const Color(0xFFAB47BC), // Slightly lighter purple + ), + ), + ); + } + + // Sort alphabetically by label + activeModes.sort((a, b) => a.key.compareTo(b.key)); + final activeChips = activeModes.map((entry) => entry.value).toList(); + + return Wrap(spacing: 8, runSpacing: -4, children: activeChips); + }, + ); + } + + String? _getFollowersDuration() { + if (roomState.followersOnly == '0') { + return null; // No duration for immediate followers + } + final minutes = int.parse(roomState.followersOnly); + if (minutes >= 60) { + final hours = minutes ~/ 60; + final remainingMinutes = minutes % 60; + if (remainingMinutes == 0) { + return '${hours}h'; + } else { + return '${hours}h ${remainingMinutes}m'; + } + } + return '${minutes}m'; + } + + String _getSlowModeDuration() { + final seconds = int.parse(roomState.slowMode); + if (seconds >= 60) { + final minutes = seconds ~/ 60; + final remainingSeconds = seconds % 60; + if (remainingSeconds == 0) { + return '${minutes}m'; + } else { + return '${minutes}m ${remainingSeconds}s'; + } + } + return '${seconds}s'; + } + + Widget _buildModeChip({ + required BuildContext context, + required IconData icon, + required IconData activeIcon, + required String label, + required String activeLabel, + required bool isActive, + required Color activeColor, + String? duration, + }) { + return Chip( + avatar: Icon( + isActive ? activeIcon : icon, + size: 16, + color: isActive ? activeColor : null, + ), + label: duration != null + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(isActive ? activeLabel : label), + const SizedBox(width: 4), + Text( + duration, + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ) + : Text(isActive ? activeLabel : label), + side: BorderSide.none, ); } } diff --git a/lib/screens/channel/chat/details/chat_users_list.dart b/lib/screens/channel/chat/details/chat_users_list.dart index daa0b472..ddaabb1c 100644 --- a/lib/screens/channel/chat/details/chat_users_list.dart +++ b/lib/screens/channel/chat/details/chat_users_list.dart @@ -5,9 +5,10 @@ import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/screens/channel/chat/details/chat_details_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart'; -import 'package:frosty/screens/settings/stores/auth_store.dart'; +import 'package:frosty/utils/modal_bottom_sheet.dart'; import 'package:frosty/widgets/alert_message.dart'; import 'package:frosty/widgets/animated_scroll_border.dart'; +import 'package:frosty/widgets/frosty_scrollbar.dart'; import 'package:frosty/widgets/scroll_to_top_button.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; @@ -46,8 +47,8 @@ class _ChattersListState extends State { decoration: InputDecoration( prefixIcon: const Icon(Icons.filter_list_rounded), hintText: 'Filter chatters', - suffixIcon: widget - .chatDetailsStore.textFieldFocusNode.hasFocus || + suffixIcon: + widget.chatDetailsStore.textFieldFocusNode.hasFocus || widget.chatDetailsStore.filterText.isNotEmpty ? IconButton( tooltip: widget.chatDetailsStore.filterText.isEmpty @@ -81,7 +82,7 @@ class _ChattersListState extends State { child: Stack( alignment: AlignmentDirectional.bottomCenter, children: [ - Scrollbar( + FrostyScrollbar( controller: widget.chatDetailsStore.scrollController, child: Observer( builder: (context) { @@ -108,11 +109,14 @@ class _ChattersListState extends State { if (widget.chatDetailsStore.chatUsers.isEmpty) const SliverFillRemaining( hasScrollBody: false, - child: - AlertMessage(message: 'No chatters found'), + child: AlertMessage( + message: 'No chatters found', + ), ) else if (widget - .chatDetailsStore.filteredUsers.isEmpty) + .chatDetailsStore + .filteredUsers + .isEmpty) const SliverFillRemaining( hasScrollBody: false, child: AlertMessage( @@ -133,19 +137,18 @@ class _ChattersListState extends State { ), ), onTap: () async { - final userInfo = - await context.read().getUser( - headers: context - .read() - .headersTwitch, - userLogin: widget.chatDetailsStore - .filteredUsers - .elementAt(index), - ); + final userInfo = await context + .read() + .getUser( + userLogin: widget + .chatDetailsStore + .filteredUsers + .elementAt(index), + ); if (!context.mounted) return; - showModalBottomSheet( + showModalBottomSheetWithProperFocus( isScrollControlled: true, context: context, builder: (context) => ChatUserModal( @@ -158,7 +161,9 @@ class _ChattersListState extends State { }, ), itemCount: widget - .chatDetailsStore.filteredUsers.length, + .chatDetailsStore + .filteredUsers + .length, ), ], ); diff --git a/lib/screens/channel/chat/emote_menu/emote_menu_panel.dart b/lib/screens/channel/chat/emote_menu/emote_menu_panel.dart index 928810f5..20c1f2bc 100644 --- a/lib/screens/channel/chat/emote_menu/emote_menu_panel.dart +++ b/lib/screens/channel/chat/emote_menu/emote_menu_panel.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:frosty/models/emotes.dart'; import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_section.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; -import 'package:frosty/widgets/page_view.dart'; +import 'package:frosty/widgets/frosty_page_view.dart'; class EmoteMenuPanel extends StatelessWidget { final ChatStore chatStore; @@ -42,14 +42,8 @@ class EmoteMenuPanel extends StatelessWidget { headers: [if (channelEmotes.isNotEmpty) 'Channel', 'Global'], children: [ if (channelEmotes.isNotEmpty) - EmoteMenuSection( - chatStore: chatStore, - emotes: channelEmotes, - ), - EmoteMenuSection( - chatStore: chatStore, - emotes: globalEmotes, - ), + EmoteMenuSection(chatStore: chatStore, emotes: channelEmotes), + EmoteMenuSection(chatStore: chatStore, emotes: globalEmotes), ], ); } else { @@ -60,8 +54,9 @@ class EmoteMenuPanel extends StatelessWidget { ); return FrostyPageView( - headers: - twitchEmotes!.keys.map((header) => header.split(' ')[0]).toList(), + headers: twitchEmotes!.keys + .map((header) => header.split(' ')[0]) + .toList(), children: twitchEmotes!.entries .map( (e) => EmoteMenuSection( diff --git a/lib/screens/channel/chat/emote_menu/emote_menu_section.dart b/lib/screens/channel/chat/emote_menu/emote_menu_section.dart index ddecf044..52005c0c 100644 --- a/lib/screens/channel/chat/emote_menu/emote_menu_section.dart +++ b/lib/screens/channel/chat/emote_menu/emote_menu_section.dart @@ -4,9 +4,8 @@ import 'package:frosty/constants.dart'; import 'package:frosty/models/emotes.dart'; import 'package:frosty/models/irc.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; -import 'package:frosty/screens/settings/stores/settings_store.dart'; -import 'package:frosty/widgets/cached_image.dart'; -import 'package:provider/provider.dart'; +import 'package:frosty/utils/context_extensions.dart'; +import 'package:frosty/widgets/frosty_cached_network_image.dart'; class EmoteMenuSection extends StatefulWidget { final ChatStore chatStore; @@ -30,45 +29,49 @@ class _EmoteMenuSectionState extends State Widget build(BuildContext context) { super.build(context); - return GridView.builder( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: - MediaQuery.of(context).orientation == Orientation.portrait - ? 8 - : context.read().showVideo - ? 6 - : 16, - ), - itemBuilder: (context, index) => InkWell( - onTap: widget.disabled - ? null - : () => widget.chatStore.addEmote(widget.emotes[index]), - onLongPress: () { - HapticFeedback.lightImpact(); + return MediaQuery.removePadding( + context: context, + removeTop: true, + child: GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: context.isPortrait + ? 8 + : context.settingsStore.showVideo + ? 6 + : 16, + ), + padding: EdgeInsets.zero, + itemBuilder: (context, index) => InkWell( + onTap: widget.disabled + ? null + : () => widget.chatStore.addEmote(widget.emotes[index]), + onLongPress: () { + HapticFeedback.lightImpact(); - IRCMessage.showEmoteDetailsBottomSheet( - context, - emote: widget.emotes[index], - launchExternal: widget.chatStore.settings.launchUrlExternal, - ); - }, - child: Padding( - padding: const EdgeInsets.all(5.0), - child: Center( - child: FrostyCachedNetworkImage( - imageUrl: widget.emotes[index].url, - height: - widget.emotes[index].height?.toDouble() ?? defaultEmoteSize, - width: widget.emotes[index].width?.toDouble(), - color: widget.disabled - ? const Color.fromRGBO(255, 255, 255, 0.5) - : null, - colorBlendMode: widget.disabled ? BlendMode.modulate : null, + IRCMessage.showEmoteDetailsBottomSheet( + context, + emote: widget.emotes[index], + launchExternal: widget.chatStore.settings.launchUrlExternal, + ); + }, + child: Padding( + padding: const EdgeInsets.all(5.0), + child: Center( + child: FrostyCachedNetworkImage( + imageUrl: widget.emotes[index].url, + height: + widget.emotes[index].height?.toDouble() ?? defaultEmoteSize, + width: widget.emotes[index].width?.toDouble(), + color: widget.disabled + ? const Color.fromRGBO(255, 255, 255, 0.5) + : null, + colorBlendMode: widget.disabled ? BlendMode.modulate : null, + ), ), ), ), + itemCount: widget.emotes.length, ), - itemCount: widget.emotes.length, ); } diff --git a/lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart b/lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart index ad7d239a..fc6d0165 100644 --- a/lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart +++ b/lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart @@ -5,87 +5,128 @@ import 'package:frosty/constants.dart'; import 'package:frosty/models/irc.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; +import 'package:frosty/utils/context_extensions.dart'; import 'package:frosty/widgets/alert_message.dart'; -import 'package:frosty/widgets/cached_image.dart'; +import 'package:frosty/widgets/frosty_cached_network_image.dart'; +import 'package:frosty/widgets/frosty_dialog.dart'; import 'package:provider/provider.dart'; class RecentEmotesPanel extends StatelessWidget { final ChatStore chatStore; - const RecentEmotesPanel({ - super.key, - required this.chatStore, - }); + const RecentEmotesPanel({super.key, required this.chatStore}); + + Future _showClearDialog(BuildContext context) { + return showDialog( + context: context, + builder: (context) => FrostyDialog( + title: 'Clear recent emotes', + message: 'Are you sure you want to clear your recent emotes?', + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + chatStore.assetsStore.recentEmotes.clear(); + chatStore.updateNotification('Recent emotes cleared'); + + Navigator.pop(context); + }, + child: const Text('Clear'), + ), + ], + ), + ); + } @override Widget build(BuildContext context) { return Observer( builder: (context) { - return CustomScrollView( - slivers: [ - if (chatStore.assetsStore.recentEmotes.isEmpty) - const SliverFillRemaining( - hasScrollBody: false, - child: AlertMessage(message: 'No recent emotes'), - ) - else - SliverGrid.builder( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: - MediaQuery.of(context).orientation == Orientation.portrait - ? 8 - : context.read().showVideo - ? 6 - : 16, - ), - itemBuilder: (context, index) { - final emote = chatStore.assetsStore.recentEmotes[index]; - final validEmotes = [ - ...chatStore.assetsStore.emoteToObject.values, - ...chatStore.assetsStore.userEmoteToObject.values, - ]; - final matchingEmotes = validEmotes.where( - (existingEmote) => - existingEmote.name == emote.name && - existingEmote.type == emote.type, - ); + return MediaQuery.removePadding( + context: context, + removeTop: true, + child: CustomScrollView( + slivers: [ + if (chatStore.assetsStore.recentEmotes.isEmpty) + const SliverFillRemaining( + hasScrollBody: false, + child: AlertMessage(message: 'No recent emotes'), + ) + else + SliverGrid.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: context.isPortrait + ? 8 + : context.read().showVideo + ? 6 + : 16, + ), + itemBuilder: (context, index) { + final emote = chatStore.assetsStore.recentEmotes[index]; + final validEmotes = [ + ...chatStore.assetsStore.emoteToObject.values, + ...chatStore.assetsStore.userEmoteToObject.values, + ]; + final matchingEmotes = validEmotes.where( + (existingEmote) => + existingEmote.name == emote.name && + existingEmote.type == emote.type, + ); - return InkWell( - onTap: matchingEmotes.isNotEmpty - ? () => chatStore.addEmote(emote) - : null, - onLongPress: () { - HapticFeedback.lightImpact(); + return InkWell( + onTap: matchingEmotes.isNotEmpty + ? () => chatStore.addEmote(emote) + : null, + onLongPress: () { + HapticFeedback.lightImpact(); - IRCMessage.showEmoteDetailsBottomSheet( - context, - emote: emote, - launchExternal: chatStore.settings.launchUrlExternal, - ); - }, - child: Padding( - padding: const EdgeInsets.all(8), - child: Center( - child: FrostyCachedNetworkImage( - imageUrl: matchingEmotes.isNotEmpty - ? matchingEmotes.first.url - : emote.url, - color: matchingEmotes.isNotEmpty - ? null - : const Color.fromRGBO(255, 255, 255, 0.5), - colorBlendMode: matchingEmotes.isNotEmpty - ? null - : BlendMode.modulate, - height: emote.height?.toDouble() ?? defaultEmoteSize, - width: emote.width?.toDouble(), + IRCMessage.showEmoteDetailsBottomSheet( + context, + emote: emote, + launchExternal: chatStore.settings.launchUrlExternal, + ); + }, + child: Padding( + padding: const EdgeInsets.all(8), + child: Center( + child: FrostyCachedNetworkImage( + imageUrl: matchingEmotes.isNotEmpty + ? matchingEmotes.first.url + : emote.url, + color: matchingEmotes.isNotEmpty + ? null + : const Color.fromRGBO(255, 255, 255, 0.5), + colorBlendMode: matchingEmotes.isNotEmpty + ? null + : BlendMode.modulate, + height: + emote.height?.toDouble() ?? defaultEmoteSize, + width: emote.width?.toDouble(), + ), ), ), + ); + }, + itemCount: chatStore.assetsStore.recentEmotes.length, + ), + SliverFillRemaining( + hasScrollBody: false, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: TextButton.icon( + onPressed: () => _showClearDialog(context), + icon: const Icon(Icons.clear_all_rounded), + label: const Text('Clear recent emotes'), ), - ); - }, - itemCount: chatStore.assetsStore.recentEmotes.length, + ), + ), ), - ], + ], + ), ); }, ); diff --git a/lib/screens/channel/chat/stores/chat_assets_store.dart b/lib/screens/channel/chat/stores/chat_assets_store.dart index fed99093..5c820479 100644 --- a/lib/screens/channel/chat/stores/chat_assets_store.dart +++ b/lib/screens/channel/chat/stores/chat_assets_store.dart @@ -6,7 +6,9 @@ import 'package:frosty/apis/seventv_api.dart'; import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/models/badges.dart'; import 'package:frosty/models/emotes.dart'; +import 'package:frosty/models/shared_chat_session.dart'; import 'package:frosty/models/user.dart'; +import 'package:frosty/stores/global_assets_store.dart'; import 'package:mobx/mobx.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -19,35 +21,61 @@ abstract class ChatAssetsStoreBase with Store { final BTTVApi bttvApi; final FFZApi ffzApi; final SevenTVApi sevenTVApi; + final GlobalAssetsStore globalAssetsStore; /// Contains any custom FFZ mod and vip badges for the channel. RoomFFZ? ffzRoomInfo; String? sevenTvEmoteSetId; + /// Tracks shared chat participant channels that we've already loaded assets for. + final Set _loadedSharedChannelIds = {}; + + /// Optional: Track 7TV set ids for shared chat participants (keyed by broadcaster id). + final sharedSevenTvSetIds = ObservableMap(); + final channelIdToUserTwitch = ObservableMap(); @computed List get bttvEmotes => - _emoteToObject.values.where((emote) => isBTTV(emote)).toList(); + emoteToObject.values.where((emote) => isBTTV(emote)).toList(); @computed List get ffzEmotes => - _emoteToObject.values.where((emote) => isFFZ(emote)).toList(); + emoteToObject.values.where((emote) => isFFZ(emote)).toList(); @computed List get sevenTVEmotes => - _emoteToObject.values.where((emote) => is7TV(emote)).toList(); - - /// The map of badges ids to their object representation. - final twitchBadgesToObject = ObservableMap(); + emoteToObject.values.where((emote) => is7TV(emote)).toList(); @readonly var _recentEmotes = ObservableList(); - /// The map of emote words to their image or GIF URL. May be used by anyone in the chat. + /// Channel-specific emotes only (not global). @readonly - var _emoteToObject = ObservableMap(); + var _channelEmoteToObject = ObservableMap(); + + /// Channel-specific Twitch badges (overrides globals). + @readonly + var _channelTwitchBadges = {}; + + /// Combined emotes: global + channel. Channel emotes take precedence. + @computed + Map get emoteToObject { + final combined = Map.from(globalAssetsStore.globalEmoteMap); + combined.addAll(_channelEmoteToObject); + return combined; + } + + /// Combined Twitch badges: global + channel. Channel badges take precedence. + @computed + Map get twitchBadgesToObject { + final combined = Map.from( + globalAssetsStore.twitchGlobalBadges, + ); + combined.addAll(_channelTwitchBadges); + return combined; + } /// The emotes that are "owned" and may be used by the current user. @readonly @@ -59,17 +87,16 @@ abstract class ChatAssetsStoreBase with Store { // ignore: prefer_final_fields var _userEmoteSectionToEmotes = >{}; - /// The map of user IDs to their FFZ badges. - @readonly - var _userToFFZBadges = >{}; + /// The map of user IDs to their FFZ badges (from global store). + Map> get userToFFZBadges => + globalAssetsStore.ffzBadges; /// The map of user IDs to their 7TV badges. @readonly var _userTo7TVBadges = >{}; - /// The map of user IDs to their BTTV badge. - @readonly - var _userToBTTVBadges = {}; + /// The map of user IDs to their BTTV badge (from global store). + Map get userToBTTVBadges => globalAssetsStore.bttvBadges; /// Whether or not the emote menu is visible. @observable @@ -98,6 +125,7 @@ abstract class ChatAssetsStoreBase with Store { required this.bttvApi, required this.ffzApi, required this.sevenTVApi, + required this.globalAssetsStore, }); @action @@ -105,7 +133,8 @@ abstract class ChatAssetsStoreBase with Store { // Retrieve the instance that will allow us to retrieve local search history. final prefs = await SharedPreferences.getInstance(); - _recentEmotes = prefs + _recentEmotes = + prefs .getStringList('recent_emotes') ?.map((emoteJson) => Emote.fromJson(jsonDecode(emoteJson))) .toList() @@ -121,7 +150,8 @@ abstract class ChatAssetsStoreBase with Store { }); } - /// Fetches global and channel assets (badges and emotes) and stores them in [_emoteToUrl] + /// Fetches channel-specific assets (badges and emotes). + /// Global assets are loaded via [globalAssetsStore] and combined automatically. @action Future assetsFuture({ required String channelId, @@ -135,97 +165,230 @@ abstract class ChatAssetsStoreBase with Store { bool showBTTVBadges = true, bool showFFZEmotes = true, bool showFFZBadges = true, - }) => - // Fetch the global and channel's assets (emotes & badges). - // Async awaits are placed in a list so they are performed in parallel. - // - // Emotes + }) async { + // Fetch global (cached) and channel-specific assets in parallel + await Future.wait([ + // Global assets (cached across all tabs - only fetches once) + globalAssetsStore.ensureLoaded( + showTwitchEmotes: showTwitchEmotes, + showTwitchBadges: showTwitchBadges, + show7TVEmotes: show7TVEmotes, + showBTTVEmotes: showBTTVEmotes, + showBTTVBadges: showBTTVBadges, + showFFZEmotes: showFFZEmotes, + showFFZBadges: showFFZBadges, + ), + // Channel-specific emotes Future.wait([ - Future.wait([ - if (showTwitchEmotes) ...[ - twitchApi - .getEmotesGlobal(headers: headers) - .catchError(onEmoteError), - twitchApi - .getEmotesChannel(id: channelId, headers: headers) - .then((emotes) { - _userEmoteSectionToEmotes.update( - 'Channel Emotes', - (existingEmoteSet) => [...existingEmoteSet, ...emotes], - ifAbsent: () => emotes.toList(), - ); - - return emotes; - }).catchError(onEmoteError), - ], - if (show7TVEmotes) ...[ - sevenTVApi.getEmotesGlobal().catchError(onEmoteError), - sevenTVApi.getEmotesChannel(id: channelId).then((data) { - final (setId, emotes) = data; - sevenTvEmoteSetId = setId; - return emotes; - }).catchError(onEmoteError), - ], - if (showBTTVEmotes) ...[ - bttvApi.getEmotesGlobal().catchError(onEmoteError), - bttvApi.getEmotesChannel(id: channelId).catchError(onEmoteError), - ], - if (showFFZEmotes) ...[ - ffzApi.getEmotesGlobal().catchError(onEmoteError), - ffzApi.getRoomInfo(id: channelId).then((ffzRoom) { - final (roomInfo, emotes) = ffzRoom; - - ffzRoomInfo = roomInfo; - return emotes; - }).catchError(onEmoteError), - ], - ]).then((assets) => assets.expand((list) => list)).then( - (emotes) => _emoteToObject = { - for (final emote in emotes) emote.name: emote, - }.asObservable(), - ), - // Badges - Future.wait([ - twitchApi - .getSharedChatSession( - broadcasterId: channelId, - headers: headers, - ) - .then((sharedChatSession) { - if (sharedChatSession == null) return; - - for (final participant in sharedChatSession.participants) { + if (showTwitchEmotes) twitchApi - .getUser(id: participant.broadcasterId, headers: headers) - .then((user) { - channelIdToUserTwitch[participant.broadcasterId] = user; - }); - } - }).catchError(onBadgeError), - // Get global badges first, then channel badges to avoid badge conflicts. - // We want the channel badges to override the global badges. - if (showTwitchBadges) - twitchApi - .getBadgesGlobal(headers: headers) - .then((badges) => twitchBadgesToObject.addAll(badges)) - .then( - (_) => twitchApi - .getBadgesChannel(id: channelId, headers: headers) - .then((badges) => twitchBadgesToObject.addAll(badges)) - .catchError(onBadgeError), - ), - if (showFFZBadges) - ffzApi - .getBadges() - .then((badges) => _userToFFZBadges = badges) - .catchError(onBadgeError), - if (showBTTVBadges) - bttvApi - .getBadges() - .then((badges) => _userToBTTVBadges = badges) - .catchError(onBadgeError), - ]), - ]); + .getEmotesChannel(id: channelId) + .then((emotes) { + _userEmoteSectionToEmotes.update( + 'Channel Emotes', + (existingEmoteSet) => [...existingEmoteSet, ...emotes], + ifAbsent: () => emotes.toList(), + ); + return emotes; + }) + .catchError(onEmoteError), + if (show7TVEmotes) + sevenTVApi + .getEmotesChannel(id: channelId) + .then((data) { + final (setId, emotes) = data; + sevenTvEmoteSetId = setId; + return emotes; + }) + .catchError(onEmoteError), + if (showBTTVEmotes) + bttvApi.getEmotesChannel(id: channelId).catchError(onEmoteError), + if (showFFZEmotes) + ffzApi + .getRoomInfo(id: channelId) + .then((ffzRoom) { + final (roomInfo, emotes) = ffzRoom; + ffzRoomInfo = roomInfo; + return emotes; + }) + .catchError(onEmoteError), + ]) + .then((assets) => assets.expand((list) => list)) + .then( + (emotes) => _channelEmoteToObject = { + for (final emote in emotes) emote.name: emote, + }.asObservable(), + ), + // Channel badges (Twitch only - FFZ/BTTV badges are global) + if (showTwitchBadges) + twitchApi + .getBadgesChannel(id: channelId) + .then((badges) => _channelTwitchBadges = badges) + .catchError(onBadgeError), + ]); + } + + /// Fetch and merge assets for all participants in a shared chat session. + /// Safe to call multiple times; only new participant channels will be fetched. + @action + Future fetchSharedChatAssets({ + required String channelId, + required Map headers, + required Function onEmoteError, + required Function onBadgeError, + bool showTwitchEmotes = true, + bool showTwitchBadges = true, + bool show7TVEmotes = true, + bool showBTTVEmotes = true, + bool showFFZEmotes = true, + bool showFFZBadges = true, + bool force = false, + }) async { + if (force) { + _loadedSharedChannelIds.clear(); + sharedSevenTvSetIds.clear(); + } + + final newParticipantIds = await populateSharedChatParticipants( + channelId: channelId, + onBadgeError: onBadgeError, + ); + + if (newParticipantIds.isEmpty) return; + + // Fetch emotes and badges for all new participants in parallel. + final futures = >[]; + + // Add emote futures for each participant. + futures.addAll( + newParticipantIds.map( + (id) => + _fetchEmotesForChannel( + id, + onEmoteError: onEmoteError, + showTwitchEmotes: showTwitchEmotes, + show7TVEmotes: show7TVEmotes, + showBTTVEmotes: showBTTVEmotes, + showFFZEmotes: showFFZEmotes, + ).then((emotes) { + for (final emote in emotes) { + _channelEmoteToObject[emote.name] = emote; + } + }), + ), + ); + + // Add badge futures for each participant. + if (showTwitchBadges) { + futures.addAll( + newParticipantIds.map( + (id) => _fetchBadgesForChannel(id, onBadgeError: onBadgeError), + ), + ); + } + + await Future.wait(futures); + } + + /// Load and cache shared chat participants for a broadcaster. + /// Returns the list of new participant broadcaster IDs (deduped). + @action + Future> populateSharedChatParticipants({ + required String channelId, + required Function onBadgeError, + }) async { + SharedChatSession? sharedSession; + try { + sharedSession = await twitchApi.getSharedChatSession( + broadcasterId: channelId, + ); + } catch (e) { + onBadgeError(e); + return []; + } + + if (sharedSession == null) return []; + + final newParticipantIds = []; + for (final participant in sharedSession.participants) { + final id = participant.broadcasterId; + if (_loadedSharedChannelIds.add(id)) { + newParticipantIds.add(id); + } + } + + // Fetch user profiles concurrently. + if (newParticipantIds.isNotEmpty) { + await Future.wait( + newParticipantIds.map((id) async { + try { + final user = await twitchApi.getUser(id: id); + channelIdToUserTwitch[id] = user; + } catch (e) { + onBadgeError(e); + } + }), + ); + } + + return newParticipantIds; + } + + Future> _fetchEmotesForChannel( + String id, { + required Function onEmoteError, + bool showTwitchEmotes = true, + bool show7TVEmotes = true, + bool showBTTVEmotes = true, + bool showFFZEmotes = true, + }) async { + final futures = >>[]; + + if (showTwitchEmotes) { + futures.add(twitchApi.getEmotesChannel(id: id).catchError(onEmoteError)); + } + if (show7TVEmotes) { + futures.add( + sevenTVApi + .getEmotesChannel(id: id) + .then((data) { + final (setId, emotes) = data; + sharedSevenTvSetIds[id] = setId; + return emotes; + }) + .catchError(onEmoteError), + ); + } + if (showBTTVEmotes) { + futures.add(bttvApi.getEmotesChannel(id: id).catchError(onEmoteError)); + } + if (showFFZEmotes) { + futures.add( + ffzApi + .getRoomInfo(id: id) + .then((ffzRoom) { + final (roomInfoIgnored, emotes) = ffzRoom; + return emotes; + }) + .catchError(onEmoteError), + ); + } + + final lists = await Future.wait(futures); + return lists.expand((e) => e).toList(); + } + + Future _fetchBadgesForChannel( + String id, { + required Function onBadgeError, + }) async { + try { + final badges = await twitchApi.getBadgesChannel(id: id); + _channelTwitchBadges = {..._channelTwitchBadges, ...badges}; + } catch (e) { + onBadgeError(e); + } + } @action Future userEmotesFuture({ @@ -235,9 +398,7 @@ abstract class ChatAssetsStoreBase with Store { }) async { final userEmotes = await Future.wait( emoteSets.map( - (setId) => twitchApi - .getEmotesSets(setId: setId, headers: headers) - .catchError(onError), + (setId) => twitchApi.getEmotesSets(setId: setId).catchError(onError), ), ); @@ -254,10 +415,7 @@ abstract class ChatAssetsStoreBase with Store { ifAbsent: () => emoteSet, ); } else { - final owner = await twitchApi.getUser( - id: ownerId, - headers: headers, - ); + final owner = await twitchApi.getUser(id: ownerId); _userEmoteSectionToEmotes.update( owner.displayName, (existingEmoteSet) => [...existingEmoteSet, ...emoteSet], diff --git a/lib/screens/channel/chat/stores/chat_assets_store.g.dart b/lib/screens/channel/chat/stores/chat_assets_store.g.dart index 46323eee..9067032c 100644 --- a/lib/screens/channel/chat/stores/chat_assets_store.g.dart +++ b/lib/screens/channel/chat/stores/chat_assets_store.g.dart @@ -12,27 +12,46 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store { Computed>? _$bttvEmotesComputed; @override - List get bttvEmotes => - (_$bttvEmotesComputed ??= Computed>(() => super.bttvEmotes, - name: 'ChatAssetsStoreBase.bttvEmotes')) - .value; + List get bttvEmotes => (_$bttvEmotesComputed ??= Computed>( + () => super.bttvEmotes, + name: 'ChatAssetsStoreBase.bttvEmotes', + )).value; Computed>? _$ffzEmotesComputed; @override - List get ffzEmotes => - (_$ffzEmotesComputed ??= Computed>(() => super.ffzEmotes, - name: 'ChatAssetsStoreBase.ffzEmotes')) - .value; + List get ffzEmotes => (_$ffzEmotesComputed ??= Computed>( + () => super.ffzEmotes, + name: 'ChatAssetsStoreBase.ffzEmotes', + )).value; Computed>? _$sevenTVEmotesComputed; @override - List get sevenTVEmotes => (_$sevenTVEmotesComputed ??= - Computed>(() => super.sevenTVEmotes, - name: 'ChatAssetsStoreBase.sevenTVEmotes')) - .value; + List get sevenTVEmotes => + (_$sevenTVEmotesComputed ??= Computed>( + () => super.sevenTVEmotes, + name: 'ChatAssetsStoreBase.sevenTVEmotes', + )).value; + Computed>? _$emoteToObjectComputed; - late final _$_recentEmotesAtom = - Atom(name: 'ChatAssetsStoreBase._recentEmotes', context: context); + @override + Map get emoteToObject => + (_$emoteToObjectComputed ??= Computed>( + () => super.emoteToObject, + name: 'ChatAssetsStoreBase.emoteToObject', + )).value; + Computed>? _$twitchBadgesToObjectComputed; + + @override + Map get twitchBadgesToObject => + (_$twitchBadgesToObjectComputed ??= Computed>( + () => super.twitchBadgesToObject, + name: 'ChatAssetsStoreBase.twitchBadgesToObject', + )).value; + + late final _$_recentEmotesAtom = Atom( + name: 'ChatAssetsStoreBase._recentEmotes', + context: context, + ); ObservableList get recentEmotes { _$_recentEmotesAtom.reportRead(); @@ -49,26 +68,59 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store { }); } - late final _$_emoteToObjectAtom = - Atom(name: 'ChatAssetsStoreBase._emoteToObject', context: context); + late final _$_channelEmoteToObjectAtom = Atom( + name: 'ChatAssetsStoreBase._channelEmoteToObject', + context: context, + ); - ObservableMap get emoteToObject { - _$_emoteToObjectAtom.reportRead(); - return super._emoteToObject; + ObservableMap get channelEmoteToObject { + _$_channelEmoteToObjectAtom.reportRead(); + return super._channelEmoteToObject; } @override - ObservableMap get _emoteToObject => emoteToObject; + ObservableMap get _channelEmoteToObject => + channelEmoteToObject; @override - set _emoteToObject(ObservableMap value) { - _$_emoteToObjectAtom.reportWrite(value, super._emoteToObject, () { - super._emoteToObject = value; - }); + set _channelEmoteToObject(ObservableMap value) { + _$_channelEmoteToObjectAtom.reportWrite( + value, + super._channelEmoteToObject, + () { + super._channelEmoteToObject = value; + }, + ); } - late final _$_userEmoteToObjectAtom = - Atom(name: 'ChatAssetsStoreBase._userEmoteToObject', context: context); + late final _$_channelTwitchBadgesAtom = Atom( + name: 'ChatAssetsStoreBase._channelTwitchBadges', + context: context, + ); + + Map get channelTwitchBadges { + _$_channelTwitchBadgesAtom.reportRead(); + return super._channelTwitchBadges; + } + + @override + Map get _channelTwitchBadges => channelTwitchBadges; + + @override + set _channelTwitchBadges(Map value) { + _$_channelTwitchBadgesAtom.reportWrite( + value, + super._channelTwitchBadges, + () { + super._channelTwitchBadges = value; + }, + ); + } + + late final _$_userEmoteToObjectAtom = Atom( + name: 'ChatAssetsStoreBase._userEmoteToObject', + context: context, + ); Map get userEmoteToObject { _$_userEmoteToObjectAtom.reportRead(); @@ -86,7 +138,9 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store { } late final _$_userEmoteSectionToEmotesAtom = Atom( - name: 'ChatAssetsStoreBase._userEmoteSectionToEmotes', context: context); + name: 'ChatAssetsStoreBase._userEmoteSectionToEmotes', + context: context, + ); Map> get userEmoteSectionToEmotes { _$_userEmoteSectionToEmotesAtom.reportRead(); @@ -99,32 +153,19 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store { @override set _userEmoteSectionToEmotes(Map> value) { - _$_userEmoteSectionToEmotesAtom - .reportWrite(value, super._userEmoteSectionToEmotes, () { - super._userEmoteSectionToEmotes = value; - }); + _$_userEmoteSectionToEmotesAtom.reportWrite( + value, + super._userEmoteSectionToEmotes, + () { + super._userEmoteSectionToEmotes = value; + }, + ); } - late final _$_userToFFZBadgesAtom = - Atom(name: 'ChatAssetsStoreBase._userToFFZBadges', context: context); - - Map> get userToFFZBadges { - _$_userToFFZBadgesAtom.reportRead(); - return super._userToFFZBadges; - } - - @override - Map> get _userToFFZBadges => userToFFZBadges; - - @override - set _userToFFZBadges(Map> value) { - _$_userToFFZBadgesAtom.reportWrite(value, super._userToFFZBadges, () { - super._userToFFZBadges = value; - }); - } - - late final _$_userTo7TVBadgesAtom = - Atom(name: 'ChatAssetsStoreBase._userTo7TVBadges', context: context); + late final _$_userTo7TVBadgesAtom = Atom( + name: 'ChatAssetsStoreBase._userTo7TVBadges', + context: context, + ); Map> get userTo7TVBadges { _$_userTo7TVBadgesAtom.reportRead(); @@ -141,26 +182,10 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store { }); } - late final _$_userToBTTVBadgesAtom = - Atom(name: 'ChatAssetsStoreBase._userToBTTVBadges', context: context); - - Map get userToBTTVBadges { - _$_userToBTTVBadgesAtom.reportRead(); - return super._userToBTTVBadges; - } - - @override - Map get _userToBTTVBadges => userToBTTVBadges; - - @override - set _userToBTTVBadges(Map value) { - _$_userToBTTVBadgesAtom.reportWrite(value, super._userToBTTVBadges, () { - super._userToBTTVBadges = value; - }); - } - - late final _$showEmoteMenuAtom = - Atom(name: 'ChatAssetsStoreBase.showEmoteMenu', context: context); + late final _$showEmoteMenuAtom = Atom( + name: 'ChatAssetsStoreBase.showEmoteMenu', + context: context, + ); @override bool get showEmoteMenu { @@ -175,60 +200,124 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store { }); } - late final _$initAsyncAction = - AsyncAction('ChatAssetsStoreBase.init', context: context); + late final _$initAsyncAction = AsyncAction( + 'ChatAssetsStoreBase.init', + context: context, + ); @override Future init() { return _$initAsyncAction.run(() => super.init()); } - late final _$userEmotesFutureAsyncAction = - AsyncAction('ChatAssetsStoreBase.userEmotesFuture', context: context); + late final _$assetsFutureAsyncAction = AsyncAction( + 'ChatAssetsStoreBase.assetsFuture', + context: context, + ); @override - Future userEmotesFuture( - {required List emoteSets, - required Map headers, - required Function onError}) { - return _$userEmotesFutureAsyncAction.run(() => super.userEmotesFuture( - emoteSets: emoteSets, headers: headers, onError: onError)); + Future assetsFuture({ + required String channelId, + required Map headers, + required Function onEmoteError, + required Function onBadgeError, + bool showTwitchEmotes = true, + bool showTwitchBadges = true, + bool show7TVEmotes = true, + bool showBTTVEmotes = true, + bool showBTTVBadges = true, + bool showFFZEmotes = true, + bool showFFZBadges = true, + }) { + return _$assetsFutureAsyncAction.run( + () => super.assetsFuture( + channelId: channelId, + headers: headers, + onEmoteError: onEmoteError, + onBadgeError: onBadgeError, + showTwitchEmotes: showTwitchEmotes, + showTwitchBadges: showTwitchBadges, + show7TVEmotes: show7TVEmotes, + showBTTVEmotes: showBTTVEmotes, + showBTTVBadges: showBTTVBadges, + showFFZEmotes: showFFZEmotes, + showFFZBadges: showFFZBadges, + ), + ); } - late final _$ChatAssetsStoreBaseActionController = - ActionController(name: 'ChatAssetsStoreBase', context: context); + late final _$fetchSharedChatAssetsAsyncAction = AsyncAction( + 'ChatAssetsStoreBase.fetchSharedChatAssets', + context: context, + ); @override - Future assetsFuture( - {required String channelId, - required Map headers, - required Function onEmoteError, - required Function onBadgeError, - bool showTwitchEmotes = true, - bool showTwitchBadges = true, - bool show7TVEmotes = true, - bool showBTTVEmotes = true, - bool showBTTVBadges = true, - bool showFFZEmotes = true, - bool showFFZBadges = true}) { - final _$actionInfo = _$ChatAssetsStoreBaseActionController.startAction( - name: 'ChatAssetsStoreBase.assetsFuture'); - try { - return super.assetsFuture( - channelId: channelId, - headers: headers, - onEmoteError: onEmoteError, - onBadgeError: onBadgeError, - showTwitchEmotes: showTwitchEmotes, - showTwitchBadges: showTwitchBadges, - show7TVEmotes: show7TVEmotes, - showBTTVEmotes: showBTTVEmotes, - showBTTVBadges: showBTTVBadges, - showFFZEmotes: showFFZEmotes, - showFFZBadges: showFFZBadges); - } finally { - _$ChatAssetsStoreBaseActionController.endAction(_$actionInfo); - } + Future fetchSharedChatAssets({ + required String channelId, + required Map headers, + required Function onEmoteError, + required Function onBadgeError, + bool showTwitchEmotes = true, + bool showTwitchBadges = true, + bool show7TVEmotes = true, + bool showBTTVEmotes = true, + bool showFFZEmotes = true, + bool showFFZBadges = true, + bool force = false, + }) { + return _$fetchSharedChatAssetsAsyncAction.run( + () => super.fetchSharedChatAssets( + channelId: channelId, + headers: headers, + onEmoteError: onEmoteError, + onBadgeError: onBadgeError, + showTwitchEmotes: showTwitchEmotes, + showTwitchBadges: showTwitchBadges, + show7TVEmotes: show7TVEmotes, + showBTTVEmotes: showBTTVEmotes, + showFFZEmotes: showFFZEmotes, + showFFZBadges: showFFZBadges, + force: force, + ), + ); + } + + late final _$populateSharedChatParticipantsAsyncAction = AsyncAction( + 'ChatAssetsStoreBase.populateSharedChatParticipants', + context: context, + ); + + @override + Future> populateSharedChatParticipants({ + required String channelId, + required Function onBadgeError, + }) { + return _$populateSharedChatParticipantsAsyncAction.run( + () => super.populateSharedChatParticipants( + channelId: channelId, + onBadgeError: onBadgeError, + ), + ); + } + + late final _$userEmotesFutureAsyncAction = AsyncAction( + 'ChatAssetsStoreBase.userEmotesFuture', + context: context, + ); + + @override + Future userEmotesFuture({ + required List emoteSets, + required Map headers, + required Function onError, + }) { + return _$userEmotesFutureAsyncAction.run( + () => super.userEmotesFuture( + emoteSets: emoteSets, + headers: headers, + onError: onError, + ), + ); } @override @@ -237,7 +326,9 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store { showEmoteMenu: ${showEmoteMenu}, bttvEmotes: ${bttvEmotes}, ffzEmotes: ${ffzEmotes}, -sevenTVEmotes: ${sevenTVEmotes} +sevenTVEmotes: ${sevenTVEmotes}, +emoteToObject: ${emoteToObject}, +twitchBadgesToObject: ${twitchBadgesToObject} '''; } } diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index 392f1587..0d418745 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/models/badges.dart'; import 'package:frosty/models/emotes.dart'; @@ -13,7 +14,6 @@ import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/utils.dart'; import 'package:mobx/mobx.dart'; -import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; part 'chat_store.g.dart'; @@ -97,9 +97,30 @@ abstract class ChatStoreBase with Store { /// This is used as an optimization to prevent the list from being updated/shifted while the user is scrolling. final messageBuffer = ObservableList(); + /// The set of message IDs that have been revealed by the user (for deleted messages). + final revealedMessageIds = ObservableSet(); + + @action + void revealMessage(String id) { + revealedMessageIds.add(id); + } + /// Timer used for dismissing the notification. Timer? _notificationTimer; + /// Timer used for resetting the sending state if no USERSTATE is received. + Timer? _sendingTimeoutTimer; + + /// Timer used for updating the chat delay countdown message. + Timer? _chatDelayCountdownTimer; + + /// Reference to the current countdown message for direct updates (avoids O(n) search). + IRCMessage? _countdownMessage; + + /// Tracks whether the initial chat delay sync has completed for the current session. + /// Prevents the countdown from re-appearing when latency updates arrive after initial sync. + bool _chatDelaySyncCompleted = false; + /// The current timer for the sleep timer if active. Timer? sleepTimer; @@ -146,6 +167,14 @@ abstract class ChatStoreBase with Store { @readonly var _showMentionAutocomplete = false; + /// Whether a message is currently being sent and waiting for server confirmation. + @readonly + var _isSendingMessage = false; + + /// Whether the chat is currently in shared chat mode (based on source-room-id tag presence). + @readonly + var _isInSharedChatMode = false; + /// The logged-in user's appearance in chat. @readonly var _userState = const USERSTATE(); @@ -156,6 +185,12 @@ abstract class ChatStoreBase with Store { @observable IRCMessage? replyingToMessage; + /// Public getter for whether a message is currently being sent. + bool get isSendingMessage => _isSendingMessage; + + /// Public getter for whether the chat is in shared chat mode. + bool get isInSharedChatMode => _isInSharedChatMode; + ChatStoreBase({ required this.twitchApi, required this.auth, @@ -166,16 +201,10 @@ abstract class ChatStoreBase with Store { required this.channelId, required this.displayName, }) { - // Enable wakelock to prevent the chat from sleeping. - if (settings.chatOnlyPreventSleep) WakelockPlus.enable(); - // Create a reaction that will reconnect to chat when logging in or out. // Closing the channel will trigger a reconnect with the new credentials. reactions.add( - reaction( - (_) => auth.isLoggedIn, - (_) => _channel?.sink.close(1000), - ), + reaction((_) => auth.isLoggedIn, (_) => _channel?.sink.close(1000)), ); reactions.add( @@ -206,43 +235,38 @@ abstract class ChatStoreBase with Store { ), ); + // Start chat delay countdown when toggling video on, cancel when off + reactions.add( + reaction((_) => settings.showVideo, (showVideo) { + if (showVideo && settings.chatDelay > 0) { + _startChatDelayCountdown(); + } else if (!showVideo) { + _cancelChatDelayCountdown(); + _chatDelaySyncCompleted = false; + } + }), + ); + + // Start chat delay countdown when chatDelay is set (for auto sync mode) + // Cancel countdown if delay becomes 0 + reactions.add( + reaction((_) => settings.chatDelay, (chatDelay) { + if (chatDelay == 0) { + _cancelChatDelayCountdown(); + _chatDelaySyncCompleted = false; + } else if (settings.autoSyncChatDelay && + settings.showVideo && + chatDelay > 0 && + !_chatDelaySyncCompleted) { + _startChatDelayCountdown(); + } + }), + ); + assetsStore.init(); _messages.add(IRCMessage.createNotice(message: 'Connecting to chat...')); - if (settings.showVideo && settings.chatDelay > 0) { - _messages.add( - IRCMessage.createNotice( - message: - 'Waiting ${settings.chatDelay.toInt()} ${settings.chatDelay == 1.0 ? 'second' : 'seconds'} due to message delay setting...', - ), - ); - } - - reactions.add( - reaction( - (_) => settings.showVideo, - (showVideo) { - if (settings.chatDelay > 0) { - if (showVideo) { - _messages.add( - IRCMessage.createNotice( - message: - 'Waiting ${settings.chatDelay.toInt()} ${settings.chatDelay == 1.0 ? 'second' : 'seconds'} due to message delay setting...', - ), - ); - } else { - _messages.add( - IRCMessage.createNotice( - message: 'Removing message delay...', - ), - ); - } - } - }, - ), - ); - if (settings.showRecentMessages) { getRecentMessage().then((_) => connectToChat()); } else { @@ -275,12 +299,14 @@ abstract class ChatStoreBase with Store { textController.addListener(() { _inputText = textController.text; - _showEmoteAutocomplete = !_showMentionAutocomplete && + _showEmoteAutocomplete = + !_showMentionAutocomplete && textFieldFocusNode.hasFocus && textController.text.split(' ').last.isNotEmpty; _showSendButton = textController.text.isNotEmpty; - _showMentionAutocomplete = textFieldFocusNode.hasFocus && + _showMentionAutocomplete = + textFieldFocusNode.hasFocus && textController.text.split(' ').last.startsWith('@'); }); } @@ -296,8 +322,10 @@ abstract class ChatStoreBase with Store { for (final message in data.trimRight().split('\r\n')) { // debugPrint('$message\n'); if (message.startsWith('@')) { - final parsedIRCMessage = - IRCMessage.fromString(message, userLogin: auth.user.details?.login); + final parsedIRCMessage = IRCMessage.fromString( + message, + userLogin: auth.user.details?.login, + ); if (parsedIRCMessage.user != null) { chatDetailsStore.chatUsers.add(parsedIRCMessage.user!); @@ -334,6 +362,32 @@ abstract class ChatStoreBase with Store { case Command.privateMessage: case Command.notice: case Command.userNotice: + // Update shared chat mode based on source-room-id tag presence + final wasShared = _isInSharedChatMode; + _isInSharedChatMode = parsedIRCMessage.tags.containsKey( + 'source-room-id', + ); + // On transition into shared chat mode, fetch assets for participants. + if (!wasShared && _isInSharedChatMode) { + assetsStore.fetchSharedChatAssets( + channelId: channelId, + headers: auth.headersTwitch, + onEmoteError: (error) { + debugPrint(error.toString()); + return []; + }, + onBadgeError: (error) { + debugPrint(error.toString()); + return []; + }, + showTwitchEmotes: settings.showTwitchEmotes, + showTwitchBadges: settings.showTwitchBadges, + show7TVEmotes: settings.show7TVEmotes, + showBTTVEmotes: settings.showBTTVEmotes, + showFFZEmotes: settings.showFFZEmotes, + showFFZBadges: settings.showFFZBadges, + ); + } messageBuffer.add(parsedIRCMessage); break; case Command.clearChat: @@ -351,8 +405,8 @@ abstract class ChatStoreBase with Store { ); break; case Command.roomState: - chatDetailsStore.roomState = - chatDetailsStore.roomState.fromIRCMessage(parsedIRCMessage); + chatDetailsStore.roomState = chatDetailsStore.roomState + .fromIRCMessage(parsedIRCMessage); continue; case Command.userState: _userState = _userState.fromIRCMessage(parsedIRCMessage); @@ -362,6 +416,9 @@ abstract class ChatStoreBase with Store { textController.clear(); messageBuffer.add(toSend!); toSend = null; + // Reset sending state since message was successfully sent + _sendingTimeoutTimer?.cancel(); + _isSendingMessage = false; } break; case Command.globalUserState: @@ -406,20 +463,21 @@ abstract class ChatStoreBase with Store { ); // Activate the message buffer. + // Cancel any existing timer before creating a new one to prevent duplicates on reconnect. + _messageBufferTimer?.cancel(); // Create a timer that will add messages from the buffer every 200 milliseconds. _messageBufferTimer = Timer.periodic( const Duration(milliseconds: 200), (timer) => addMessages(), ); - getAssets().then((_) { - if (!settings.show7TVEmotes) return; - + // Set up 7TV real-time listener (assets already fetched in connectToChat) + if (settings.show7TVEmotes) { final emoteSetId = assetsStore.sevenTvEmoteSetId; if (emoteSetId != null && !_shouldDisconnect) { listenToSevenTVEmoteSet(emoteSetId: emoteSetId); } - }); + } // Reset exponential backoff if successfully connected. _retries = 0; @@ -430,25 +488,52 @@ abstract class ChatStoreBase with Store { // Fetch the assets used in chat including badges and emotes. @action - Future getAssets() async => assetsStore.assetsFuture( + Future getAssets() async { + // Prepare common error handlers to avoid repetition. + List onEmoteError(dynamic error) { + debugPrint(error.toString()); + return []; + } + + List onBadgeError(dynamic error) { + debugPrint(error.toString()); + return []; + } + + final baseAssets = assetsStore.assetsFuture( + channelId: channelId, + headers: auth.headersTwitch, + onEmoteError: onEmoteError, + onBadgeError: onBadgeError, + showTwitchEmotes: settings.showTwitchEmotes, + showTwitchBadges: settings.showTwitchBadges, + show7TVEmotes: settings.show7TVEmotes, + showBTTVEmotes: settings.showBTTVEmotes, + showBTTVBadges: settings.showBTTVBadges, + showFFZEmotes: settings.showFFZEmotes, + showFFZBadges: settings.showFFZBadges, + ); + + // If shared chat mode is active, also refresh participant assets in parallel. + if (_isInSharedChatMode) { + final sharedAssets = assetsStore.fetchSharedChatAssets( channelId: channelId, headers: auth.headersTwitch, - onEmoteError: (error) { - debugPrint(error.toString()); - return []; - }, - onBadgeError: (error) { - debugPrint(error.toString()); - return []; - }, + onEmoteError: onEmoteError, + onBadgeError: onBadgeError, showTwitchEmotes: settings.showTwitchEmotes, showTwitchBadges: settings.showTwitchBadges, show7TVEmotes: settings.show7TVEmotes, showBTTVEmotes: settings.showBTTVEmotes, - showBTTVBadges: settings.showBTTVBadges, showFFZEmotes: settings.showFFZEmotes, showFFZBadges: settings.showFFZBadges, + force: true, ); + await Future.wait([baseAssets, sharedAssets]); + } else { + await baseAssets; + } + } /// Re-enables [_autoScroll] and jumps to the latest message. @action @@ -475,8 +560,13 @@ abstract class ChatStoreBase with Store { ); _sevenTVChannel?.sink.close(1000); - _sevenTVChannel = - WebSocketChannel.connect(Uri.parse('wss://events.7tv.io/v3')); + _sevenTVChannel = WebSocketChannel.connect( + Uri.parse('wss://events.7tv.io/v3'), + ); + + // Track the current connection to prevent stale delayed callbacks + final connectionId = DateTime.now().millisecondsSinceEpoch; + var currentConnectionId = connectionId; void listener(dynamic data) { // debugPrint(data); @@ -527,24 +617,47 @@ abstract class ChatStoreBase with Store { if (!settings.showVideo || settings.chatDelay == 0) { listener(data); } else { - Future.delayed( - Duration(seconds: settings.chatDelay.toInt()), - () => listener(data), - ); + final capturedConnectionId = currentConnectionId; + Future.delayed(Duration(seconds: settings.chatDelay.toInt()), () { + // Only process if this is still the active connection + if (capturedConnectionId == currentConnectionId) { + listener(data); + } + }); } }, onError: (error) => debugPrint('7TV events error: ${error.toString()}'), - onDone: () => debugPrint('7TV events done'), + onDone: () { + // Invalidate the current connection to cancel pending delayed callbacks + currentConnectionId = 0; + debugPrint('7TV events done'); + }, ); _sevenTVChannel?.sink.add(jsonEncode(subscribePayload)); } @action - void connectToChat() { + Future connectToChat({bool isReconnect = false}) async { + // Fetch assets first so they're available for all messages + getAssets(); + + // Cancel existing listener to prevent duplicate message processing + _channelListener?.cancel(); + _channel?.sink.close(1000); - _channel = - WebSocketChannel.connect(Uri.parse('wss://irc-ws.chat.twitch.tv:443')); + _channel = WebSocketChannel.connect( + Uri.parse('wss://irc-ws.chat.twitch.tv:443'), + ); + + // Only show chat delay countdown on initial connection or video toggle, not on reconnects + if (!isReconnect && settings.showVideo && settings.chatDelay > 0) { + _startChatDelayCountdown(); + } + + // Track the current connection to prevent stale delayed callbacks + final connectionId = DateTime.now().millisecondsSinceEpoch; + var currentConnectionId = connectionId; // Listen for new messages and forward them to the handler. _channelListener = _channel?.stream.listen( @@ -552,49 +665,94 @@ abstract class ChatStoreBase with Store { if (!settings.showVideo || settings.chatDelay == 0) { _handleIRCData(data.toString()); } else { - Future.delayed( - Duration(seconds: settings.chatDelay.toInt()), - () => _handleIRCData(data.toString()), - ); + final capturedConnectionId = currentConnectionId; + Future.delayed(Duration(seconds: settings.chatDelay.toInt()), () { + // Only process if this is still the active connection + if (capturedConnectionId == currentConnectionId) { + _handleIRCData(data.toString()); + } + }); } }, onError: (error) => debugPrint('Chat error: ${error.toString()}'), onDone: () async { + // Invalidate the current connection to cancel pending delayed callbacks + currentConnectionId = 0; + + // Cancel chat delay countdown when disconnecting + _cancelChatDelayCountdown(); + _chatDelaySyncCompleted = false; + if (_shouldDisconnect) { _sevenTVChannel?.sink.close(1000); return; } if (_retries >= _maxRetries) { - messageBuffer.add( + // Add directly to messages, not buffer, so it shows immediately + _messages.add( IRCMessage.createNotice( - message: 'Disconnected from chat', + message: 'Chat disconnected. Please check your connection.', + actionCallback: () { + _retries = 0; + _backoffTime = 0; + connectToChat(isReconnect: true); + }, + actionLabel: 'Reconnect', ), ); return; } if (_backoffTime > 0) { - // Add notice that chat was disconnected and then wait the backoff time before reconnecting. - final notice = - 'Disconnected from chat, waiting $_backoffTime ${_backoffTime == 1 ? 'second' : 'seconds'} before reconnecting...'; - messageBuffer.add(IRCMessage.createNotice(message: notice)); - } + // Show countdown message for backoff time + var remainingSeconds = _backoffTime; + _messages.add( + IRCMessage.createNotice( + message: + 'Connection lost. Reconnecting in ${remainingSeconds}s...', + ), + ); - await Future.delayed(Duration(seconds: _backoffTime)); + // Update countdown every second + await Future.doWhile(() async { + await Future.delayed(const Duration(seconds: 1)); + remainingSeconds--; + + // Find and replace the countdown message + final index = _messages.indexWhere( + (msg) => + msg.message?.contains('Connection lost. Reconnecting in') ?? + false, + ); + + if (index != -1 && remainingSeconds > 0) { + _messages[index] = IRCMessage.createNotice( + message: + 'Connection lost. Reconnecting in ${remainingSeconds}s...', + ); + return true; // Continue loop + } else { + // Remove countdown message when done + if (index != -1) _messages.removeAt(index); + return false; // Exit loop + } + }); + } // Increase the backoff time for the next retry. _backoffTime == 0 ? _backoffTime++ : _backoffTime *= 2; // Increment the retry count and attempt the reconnect. _retries++; - messageBuffer.add( + // Add directly to messages, not buffer, so it shows immediately + _messages.add( IRCMessage.createNotice( - message: 'Reconnecting to chat (attempt $_retries)...', + message: 'Reconnecting... (attempt $_retries of $_maxRetries)', ), ); _channelListener?.cancel(); - connectToChat(); + connectToChat(isReconnect: true); }, ); @@ -628,19 +786,116 @@ abstract class ChatStoreBase with Store { messageBuffer.clear(); } + /// Cancels the chat delay countdown and removes the countdown message. + @action + void _cancelChatDelayCountdown() { + _chatDelayCountdownTimer?.cancel(); + _chatDelayCountdownTimer = null; + + // Remove the countdown message if it exists + if (_countdownMessage != null) { + _messages.remove(_countdownMessage); + _countdownMessage = null; + } + } + + /// Starts and manages the chat delay countdown message. + @action + void _startChatDelayCountdown() { + // Cancel any existing countdown first (prevents duplicate messages) + _cancelChatDelayCountdown(); + + // Flush any buffered messages first to ensure countdown is at the bottom + if (messageBuffer.isNotEmpty) { + _messages.addAll(messageBuffer); + messageBuffer.clear(); + } + + var remainingSeconds = settings.chatDelay.toInt(); + + // Create and store reference to the countdown message + _countdownMessage = IRCMessage.createNotice( + message: 'Chat will sync in ${remainingSeconds}s...', + ); + _messages.add(_countdownMessage!); + + // Update countdown every second + _chatDelayCountdownTimer = Timer.periodic(const Duration(seconds: 1), ( + timer, + ) { + remainingSeconds--; + + // Use runInAction for proper MobX reactivity in async callbacks + runInAction(() { + // Check if countdown message still exists in the list + // (could be removed by message limit cleanup) + if (_countdownMessage == null || + !_messages.contains(_countdownMessage)) { + timer.cancel(); + _countdownMessage = null; + _chatDelayCountdownTimer = null; + return; + } + + final index = _messages.indexOf(_countdownMessage!); + if (index != -1) { + if (remainingSeconds > 0) { + // Create new message and update reference + _countdownMessage = IRCMessage.createNotice( + message: 'Chat will sync in ${remainingSeconds}s...', + ); + _messages[index] = _countdownMessage!; + } else { + // Remove countdown message when done + _messages.removeAt(index); + _countdownMessage = null; + _chatDelayCountdownTimer = null; + _chatDelaySyncCompleted = true; + timer.cancel(); + } + } else { + _countdownMessage = null; + _chatDelayCountdownTimer = null; + timer.cancel(); + } + }); + }); + } + /// Sends the given string message by the logged-in user and adds it to [_messages]. @action void sendMessage(String message) { // Do not send if the message is blank/empty. if (message.isEmpty) return; + // Prevent sending multiple messages simultaneously + if (_isSendingMessage) { + updateNotification('Please wait, sending previous message...'); + return; + } + if (_channel == null || _channel?.closeCode != null) { messageBuffer.add( IRCMessage.createNotice( - message: 'Failed to send message: disconnected from chat.', + message: + 'Cannot send message - chat is disconnected. Reconnecting...', ), ); } else { + // Set sending state to true + _isSendingMessage = true; + + // Start a timeout timer to reset sending state if no USERSTATE is received + _sendingTimeoutTimer?.cancel(); + _sendingTimeoutTimer = Timer(const Duration(seconds: 10), () { + if (_isSendingMessage) { + _isSendingMessage = false; + updateNotification( + 'Message failed to send. Try again or check connection.', + ); + } + }); + // Send the message to the IRC chat room. _channel?.sink.add( '${replyingToMessage != null ? '@reply-parent-msg-id=${replyingToMessage!.tags['id']} ' : ''}PRIVMSG #$channelName :$message', @@ -715,16 +970,22 @@ abstract class ChatStoreBase with Store { // when copying messages repeatedly. _notificationTimer?.cancel(); - // If empty, clear the notification and don't make a new timer (empty message means cancelling the notification). - if (notificationMessage.isEmpty) { - _notification = null; - return; - } + // Provide subtle haptic feedback when notification is triggered + HapticFeedback.lightImpact(); - // Set the new notification message and create a new timer that will dismiss it after 2 seconds. + // Set the new notification message and create a new timer that will dismiss it after 3 seconds. _notification = notificationMessage; - _notificationTimer = - Timer(const Duration(seconds: 3), () => _notification = null); + _notificationTimer = Timer( + const Duration(seconds: 5), + () => _notification = null, + ); + } + + /// Clears the current notification immediately. + @action + void clearNotification() { + _notificationTimer?.cancel(); + _notification = null; } /// Updates the sleep timer with the given [duration]. @@ -741,20 +1002,17 @@ abstract class ChatStoreBase with Store { timeRemaining = duration; // Set a periodic timer that will update the time remaining every second. - sleepTimer = Timer.periodic( - const Duration(seconds: 1), - (timer) { - // If the timer is up, cancel the timer and exit the app. - if (timeRemaining.inSeconds == 0) { - timer.cancel(); - onTimerFinished(); - return; - } + sleepTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + // If the timer is up, cancel the timer and exit the app. + if (timeRemaining.inSeconds == 0) { + timer.cancel(); + onTimerFinished(); + return; + } - // Decrement the time remaining. - timeRemaining = Duration(seconds: timeRemaining.inSeconds - 1); - }, - ); + // Decrement the time remaining. + timeRemaining = Duration(seconds: timeRemaining.inSeconds - 1); + }); } /// Cancels the sleep timer and resets the time remaining. @@ -766,8 +1024,9 @@ abstract class ChatStoreBase with Store { @action Future getRecentMessage() async { - final recentMessages = - await twitchApi.getRecentMessages(userLogin: channelName); + final recentMessages = await twitchApi.getRecentMessages( + userLogin: channelName, + ); for (final message in recentMessages) { _handleIRCData(message); @@ -780,6 +1039,8 @@ abstract class ChatStoreBase with Store { _messageBufferTimer?.cancel(); _notificationTimer?.cancel(); + _sendingTimeoutTimer?.cancel(); + _cancelChatDelayCountdown(); sleepTimer?.cancel(); // Cancel WebSocket subscriptions @@ -800,8 +1061,17 @@ abstract class ChatStoreBase with Store { assetsStore.dispose(); chatDetailsStore.dispose(); + } - // Disable wakelock so that the sleep timer will function properly. - WakelockPlus.disable(); + /// Unfocuses the text field. + @action + void unfocusInput() { + textFieldFocusNode.unfocus(); + } + + /// Requests focus for the text field. + @action + void safeRequestFocus() { + textFieldFocusNode.requestFocus(); } } diff --git a/lib/screens/channel/chat/stores/chat_store.g.dart b/lib/screens/channel/chat/stores/chat_store.g.dart index d26238d5..bf3d04d0 100644 --- a/lib/screens/channel/chat/stores/chat_store.g.dart +++ b/lib/screens/channel/chat/stores/chat_store.g.dart @@ -12,13 +12,16 @@ mixin _$ChatStore on ChatStoreBase, Store { Computed>? _$renderMessagesComputed; @override - List get renderMessages => (_$renderMessagesComputed ??= - Computed>(() => super.renderMessages, - name: 'ChatStoreBase.renderMessages')) - .value; + List get renderMessages => + (_$renderMessagesComputed ??= Computed>( + () => super.renderMessages, + name: 'ChatStoreBase.renderMessages', + )).value; - late final _$timeRemainingAtom = - Atom(name: 'ChatStoreBase.timeRemaining', context: context); + late final _$timeRemainingAtom = Atom( + name: 'ChatStoreBase.timeRemaining', + context: context, + ); @override Duration get timeRemaining { @@ -33,8 +36,10 @@ mixin _$ChatStore on ChatStoreBase, Store { }); } - late final _$_notificationAtom = - Atom(name: 'ChatStoreBase._notification', context: context); + late final _$_notificationAtom = Atom( + name: 'ChatStoreBase._notification', + context: context, + ); String? get notification { _$_notificationAtom.reportRead(); @@ -51,8 +56,10 @@ mixin _$ChatStore on ChatStoreBase, Store { }); } - late final _$_messagesAtom = - Atom(name: 'ChatStoreBase._messages', context: context); + late final _$_messagesAtom = Atom( + name: 'ChatStoreBase._messages', + context: context, + ); ObservableList get messages { _$_messagesAtom.reportRead(); @@ -69,8 +76,10 @@ mixin _$ChatStore on ChatStoreBase, Store { }); } - late final _$_autoScrollAtom = - Atom(name: 'ChatStoreBase._autoScroll', context: context); + late final _$_autoScrollAtom = Atom( + name: 'ChatStoreBase._autoScroll', + context: context, + ); bool get autoScroll { _$_autoScrollAtom.reportRead(); @@ -87,8 +96,10 @@ mixin _$ChatStore on ChatStoreBase, Store { }); } - late final _$_inputTextAtom = - Atom(name: 'ChatStoreBase._inputText', context: context); + late final _$_inputTextAtom = Atom( + name: 'ChatStoreBase._inputText', + context: context, + ); String get inputText { _$_inputTextAtom.reportRead(); @@ -105,8 +116,10 @@ mixin _$ChatStore on ChatStoreBase, Store { }); } - late final _$_showSendButtonAtom = - Atom(name: 'ChatStoreBase._showSendButton', context: context); + late final _$_showSendButtonAtom = Atom( + name: 'ChatStoreBase._showSendButton', + context: context, + ); bool get showSendButton { _$_showSendButtonAtom.reportRead(); @@ -123,8 +136,10 @@ mixin _$ChatStore on ChatStoreBase, Store { }); } - late final _$_showEmoteAutocompleteAtom = - Atom(name: 'ChatStoreBase._showEmoteAutocomplete', context: context); + late final _$_showEmoteAutocompleteAtom = Atom( + name: 'ChatStoreBase._showEmoteAutocomplete', + context: context, + ); bool get showEmoteAutocomplete { _$_showEmoteAutocompleteAtom.reportRead(); @@ -136,14 +151,19 @@ mixin _$ChatStore on ChatStoreBase, Store { @override set _showEmoteAutocomplete(bool value) { - _$_showEmoteAutocompleteAtom - .reportWrite(value, super._showEmoteAutocomplete, () { - super._showEmoteAutocomplete = value; - }); + _$_showEmoteAutocompleteAtom.reportWrite( + value, + super._showEmoteAutocomplete, + () { + super._showEmoteAutocomplete = value; + }, + ); } - late final _$_showMentionAutocompleteAtom = - Atom(name: 'ChatStoreBase._showMentionAutocomplete', context: context); + late final _$_showMentionAutocompleteAtom = Atom( + name: 'ChatStoreBase._showMentionAutocomplete', + context: context, + ); bool get showMentionAutocomplete { _$_showMentionAutocompleteAtom.reportRead(); @@ -155,14 +175,59 @@ mixin _$ChatStore on ChatStoreBase, Store { @override set _showMentionAutocomplete(bool value) { - _$_showMentionAutocompleteAtom - .reportWrite(value, super._showMentionAutocomplete, () { - super._showMentionAutocomplete = value; + _$_showMentionAutocompleteAtom.reportWrite( + value, + super._showMentionAutocomplete, + () { + super._showMentionAutocomplete = value; + }, + ); + } + + late final _$_isSendingMessageAtom = Atom( + name: 'ChatStoreBase._isSendingMessage', + context: context, + ); + + bool get isSendingMessage { + _$_isSendingMessageAtom.reportRead(); + return super._isSendingMessage; + } + + @override + bool get _isSendingMessage => isSendingMessage; + + @override + set _isSendingMessage(bool value) { + _$_isSendingMessageAtom.reportWrite(value, super._isSendingMessage, () { + super._isSendingMessage = value; }); } - late final _$_userStateAtom = - Atom(name: 'ChatStoreBase._userState', context: context); + late final _$_isInSharedChatModeAtom = Atom( + name: 'ChatStoreBase._isInSharedChatMode', + context: context, + ); + + bool get isInSharedChatMode { + _$_isInSharedChatModeAtom.reportRead(); + return super._isInSharedChatMode; + } + + @override + bool get _isInSharedChatMode => isInSharedChatMode; + + @override + set _isInSharedChatMode(bool value) { + _$_isInSharedChatModeAtom.reportWrite(value, super._isInSharedChatMode, () { + super._isInSharedChatMode = value; + }); + } + + late final _$_userStateAtom = Atom( + name: 'ChatStoreBase._userState', + context: context, + ); USERSTATE get userState { _$_userStateAtom.reportRead(); @@ -179,8 +244,10 @@ mixin _$ChatStore on ChatStoreBase, Store { }); } - late final _$expandChatAtom = - Atom(name: 'ChatStoreBase.expandChat', context: context); + late final _$expandChatAtom = Atom( + name: 'ChatStoreBase.expandChat', + context: context, + ); @override bool get expandChat { @@ -195,8 +262,10 @@ mixin _$ChatStore on ChatStoreBase, Store { }); } - late final _$replyingToMessageAtom = - Atom(name: 'ChatStoreBase.replyingToMessage', context: context); + late final _$replyingToMessageAtom = Atom( + name: 'ChatStoreBase.replyingToMessage', + context: context, + ); @override IRCMessage? get replyingToMessage { @@ -211,29 +280,60 @@ mixin _$ChatStore on ChatStoreBase, Store { }); } - late final _$getAssetsAsyncAction = - AsyncAction('ChatStoreBase.getAssets', context: context); + late final _$getAssetsAsyncAction = AsyncAction( + 'ChatStoreBase.getAssets', + context: context, + ); @override Future getAssets() { return _$getAssetsAsyncAction.run(() => super.getAssets()); } - late final _$getRecentMessageAsyncAction = - AsyncAction('ChatStoreBase.getRecentMessage', context: context); + late final _$connectToChatAsyncAction = AsyncAction( + 'ChatStoreBase.connectToChat', + context: context, + ); + + @override + Future connectToChat({bool isReconnect = false}) { + return _$connectToChatAsyncAction.run( + () => super.connectToChat(isReconnect: isReconnect), + ); + } + + late final _$getRecentMessageAsyncAction = AsyncAction( + 'ChatStoreBase.getRecentMessage', + context: context, + ); @override Future getRecentMessage() { return _$getRecentMessageAsyncAction.run(() => super.getRecentMessage()); } - late final _$ChatStoreBaseActionController = - ActionController(name: 'ChatStoreBase', context: context); + late final _$ChatStoreBaseActionController = ActionController( + name: 'ChatStoreBase', + context: context, + ); + + @override + void revealMessage(String id) { + final _$actionInfo = _$ChatStoreBaseActionController.startAction( + name: 'ChatStoreBase.revealMessage', + ); + try { + return super.revealMessage(id); + } finally { + _$ChatStoreBaseActionController.endAction(_$actionInfo); + } + } @override void _handleIRCData(String data) { final _$actionInfo = _$ChatStoreBaseActionController.startAction( - name: 'ChatStoreBase._handleIRCData'); + name: 'ChatStoreBase._handleIRCData', + ); try { return super._handleIRCData(data); } finally { @@ -244,7 +344,8 @@ mixin _$ChatStore on ChatStoreBase, Store { @override void resumeScroll() { final _$actionInfo = _$ChatStoreBaseActionController.startAction( - name: 'ChatStoreBase.resumeScroll'); + name: 'ChatStoreBase.resumeScroll', + ); try { return super.resumeScroll(); } finally { @@ -255,7 +356,8 @@ mixin _$ChatStore on ChatStoreBase, Store { @override void listenToSevenTVEmoteSet({required String emoteSetId}) { final _$actionInfo = _$ChatStoreBaseActionController.startAction( - name: 'ChatStoreBase.listenToSevenTVEmoteSet'); + name: 'ChatStoreBase.listenToSevenTVEmoteSet', + ); try { return super.listenToSevenTVEmoteSet(emoteSetId: emoteSetId); } finally { @@ -263,21 +365,11 @@ mixin _$ChatStore on ChatStoreBase, Store { } } - @override - void connectToChat() { - final _$actionInfo = _$ChatStoreBaseActionController.startAction( - name: 'ChatStoreBase.connectToChat'); - try { - return super.connectToChat(); - } finally { - _$ChatStoreBaseActionController.endAction(_$actionInfo); - } - } - @override void addMessages() { final _$actionInfo = _$ChatStoreBaseActionController.startAction( - name: 'ChatStoreBase.addMessages'); + name: 'ChatStoreBase.addMessages', + ); try { return super.addMessages(); } finally { @@ -285,10 +377,35 @@ mixin _$ChatStore on ChatStoreBase, Store { } } + @override + void _cancelChatDelayCountdown() { + final _$actionInfo = _$ChatStoreBaseActionController.startAction( + name: 'ChatStoreBase._cancelChatDelayCountdown', + ); + try { + return super._cancelChatDelayCountdown(); + } finally { + _$ChatStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void _startChatDelayCountdown() { + final _$actionInfo = _$ChatStoreBaseActionController.startAction( + name: 'ChatStoreBase._startChatDelayCountdown', + ); + try { + return super._startChatDelayCountdown(); + } finally { + _$ChatStoreBaseActionController.endAction(_$actionInfo); + } + } + @override void sendMessage(String message) { final _$actionInfo = _$ChatStoreBaseActionController.startAction( - name: 'ChatStoreBase.sendMessage'); + name: 'ChatStoreBase.sendMessage', + ); try { return super.sendMessage(message); } finally { @@ -299,7 +416,8 @@ mixin _$ChatStore on ChatStoreBase, Store { @override void addEmote(Emote emote, {bool autocompleteMode = false}) { final _$actionInfo = _$ChatStoreBaseActionController.startAction( - name: 'ChatStoreBase.addEmote'); + name: 'ChatStoreBase.addEmote', + ); try { return super.addEmote(emote, autocompleteMode: autocompleteMode); } finally { @@ -310,7 +428,8 @@ mixin _$ChatStore on ChatStoreBase, Store { @override void updateNotification(String notificationMessage) { final _$actionInfo = _$ChatStoreBaseActionController.startAction( - name: 'ChatStoreBase.updateNotification'); + name: 'ChatStoreBase.updateNotification', + ); try { return super.updateNotification(notificationMessage); } finally { @@ -319,13 +438,30 @@ mixin _$ChatStore on ChatStoreBase, Store { } @override - void updateSleepTimer( - {required Duration duration, required VoidCallback onTimerFinished}) { + void clearNotification() { final _$actionInfo = _$ChatStoreBaseActionController.startAction( - name: 'ChatStoreBase.updateSleepTimer'); + name: 'ChatStoreBase.clearNotification', + ); + try { + return super.clearNotification(); + } finally { + _$ChatStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void updateSleepTimer({ + required Duration duration, + required VoidCallback onTimerFinished, + }) { + final _$actionInfo = _$ChatStoreBaseActionController.startAction( + name: 'ChatStoreBase.updateSleepTimer', + ); try { return super.updateSleepTimer( - duration: duration, onTimerFinished: onTimerFinished); + duration: duration, + onTimerFinished: onTimerFinished, + ); } finally { _$ChatStoreBaseActionController.endAction(_$actionInfo); } @@ -334,7 +470,8 @@ mixin _$ChatStore on ChatStoreBase, Store { @override void cancelSleepTimer() { final _$actionInfo = _$ChatStoreBaseActionController.startAction( - name: 'ChatStoreBase.cancelSleepTimer'); + name: 'ChatStoreBase.cancelSleepTimer', + ); try { return super.cancelSleepTimer(); } finally { @@ -342,6 +479,30 @@ mixin _$ChatStore on ChatStoreBase, Store { } } + @override + void unfocusInput() { + final _$actionInfo = _$ChatStoreBaseActionController.startAction( + name: 'ChatStoreBase.unfocusInput', + ); + try { + return super.unfocusInput(); + } finally { + _$ChatStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void safeRequestFocus() { + final _$actionInfo = _$ChatStoreBaseActionController.startAction( + name: 'ChatStoreBase.safeRequestFocus', + ); + try { + return super.safeRequestFocus(); + } finally { + _$ChatStoreBaseActionController.endAction(_$actionInfo); + } + } + @override String toString() { return ''' diff --git a/lib/screens/channel/chat/stores/chat_tabs_store.dart b/lib/screens/channel/chat/stores/chat_tabs_store.dart new file mode 100644 index 00000000..428ce76b --- /dev/null +++ b/lib/screens/channel/chat/stores/chat_tabs_store.dart @@ -0,0 +1,388 @@ +import 'package:frosty/apis/bttv_api.dart'; +import 'package:frosty/apis/ffz_api.dart'; +import 'package:frosty/apis/seventv_api.dart'; +import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/screens/channel/chat/details/chat_details_store.dart'; +import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; +import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; +import 'package:frosty/screens/settings/stores/auth_store.dart'; +import 'package:frosty/screens/settings/stores/settings_store.dart'; +import 'package:frosty/stores/global_assets_store.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:mobx/mobx.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; + +part 'chat_tabs_store.g.dart'; + +/// Lightweight data class for persisting chat tab info to settings. +/// Does NOT include ChatStore since WebSocket connections cannot be persisted. +@JsonSerializable() +class PersistedChatTab { + final String channelId; + final String channelLogin; + final String displayName; + + const PersistedChatTab({ + required this.channelId, + required this.channelLogin, + required this.displayName, + }); + + factory PersistedChatTab.fromJson(Map json) => + _$PersistedChatTabFromJson(json); + Map toJson() => _$PersistedChatTabToJson(this); + + /// Create from a ChatTabInfo (for syncing current tabs to settings). + factory PersistedChatTab.fromChatTabInfo(ChatTabInfo info) => + PersistedChatTab( + channelId: info.channelId, + channelLogin: info.channelLogin, + displayName: info.displayName, + ); +} + +/// Data class holding information about a chat tab. +class ChatTabInfo { + final String channelId; + final String channelLogin; + final String displayName; + + /// The ChatStore for this tab. Null until the tab is activated (lazy loading). + ChatStore? chatStore; + + /// Whether this is the primary tab (first tab, cannot be removed). + final bool isPrimary; + + /// Whether this tab has been activated (chatStore created). + bool get isActivated => chatStore != null; + + ChatTabInfo({ + required this.channelId, + required this.channelLogin, + required this.displayName, + this.chatStore, + required this.isPrimary, + }); +} + +/// Store that manages multiple chat tabs. +class ChatTabsStore = ChatTabsStoreBase with _$ChatTabsStore; + +abstract class ChatTabsStoreBase with Store { + /// Maximum number of tabs allowed. + static const maxTabs = 10; + + /// API services needed for creating ChatStore instances. + final TwitchApi twitchApi; + final BTTVApi bttvApi; + final FFZApi ffzApi; + final SevenTVApi sevenTVApi; + + /// Global stores passed to each ChatStore. + final AuthStore authStore; + final SettingsStore settingsStore; + final GlobalAssetsStore globalAssetsStore; + + /// The list of open chat tabs. + @readonly + var _tabs = ObservableList(); + + /// The index of the currently active tab. + @observable + var activeTabIndex = 0; + + /// Returns the currently active tab info. + @computed + ChatTabInfo get activeTab => _tabs[activeTabIndex]; + + /// Returns the ChatStore of the currently active tab. + /// The active tab is always activated, so this should never be null. + @computed + ChatStore get activeChatStore => _tabs[activeTabIndex].chatStore!; + + /// Whether we can add more tabs (under the limit). + @computed + bool get canAddTab => _tabs.length < maxTabs; + + /// Whether the tab bar should be visible (more than 1 tab). + @computed + bool get showTabBar => _tabs.length > 1; + + /// Public getter for the tabs list. + List get tabs => _tabs; + + ChatTabsStoreBase({ + required this.twitchApi, + required this.bttvApi, + required this.ffzApi, + required this.sevenTVApi, + required this.authStore, + required this.settingsStore, + required this.globalAssetsStore, + required String primaryChannelId, + required String primaryChannelLogin, + required String primaryDisplayName, + }) { + // Enable wakelock once for all chat tabs + WakelockPlus.enable(); + + // Initialize with the primary channel tab + _addPrimaryTab( + channelId: primaryChannelId, + channelLogin: primaryChannelLogin, + displayName: primaryDisplayName, + ); + + // Restore persisted secondary tabs if feature is enabled + if (settingsStore.persistChatTabs) { + _restoreSecondaryTabs(primaryChannelId: primaryChannelId); + } + } + + /// Creates a ChatStore for a given channel. + ChatStore _createChatStore({ + required String channelId, + required String channelLogin, + required String displayName, + }) { + return ChatStore( + twitchApi: twitchApi, + channelName: channelLogin, + channelId: channelId, + displayName: displayName, + auth: authStore, + settings: settingsStore, + chatDetailsStore: ChatDetailsStore( + twitchApi: twitchApi, + channelName: channelLogin, + ), + assetsStore: ChatAssetsStore( + twitchApi: twitchApi, + ffzApi: ffzApi, + bttvApi: bttvApi, + sevenTVApi: sevenTVApi, + globalAssetsStore: globalAssetsStore, + ), + ); + } + + /// Activates a tab by creating its ChatStore if not already activated. + @action + void activateTab(int index) { + if (index < 0 || index >= _tabs.length) return; + + final tab = _tabs[index]; + if (tab.chatStore != null) return; // Already activated + + tab.chatStore = _createChatStore( + channelId: tab.channelId, + channelLogin: tab.channelLogin, + displayName: tab.displayName, + ); + } + + /// Adds the primary tab (called during initialization). + void _addPrimaryTab({ + required String channelId, + required String channelLogin, + required String displayName, + }) { + final chatStore = _createChatStore( + channelId: channelId, + channelLogin: channelLogin, + displayName: displayName, + ); + + _tabs.add( + ChatTabInfo( + channelId: channelId, + channelLogin: channelLogin, + displayName: displayName, + chatStore: chatStore, + isPrimary: true, + ), + ); + } + + /// Restores secondary tabs from settings (lazy - no ChatStore created). + void _restoreSecondaryTabs({required String primaryChannelId}) { + for (final persisted in settingsStore.secondaryTabs) { + // Skip if this is the same as the primary tab (already added) + if (persisted.channelId == primaryChannelId) { + continue; + } + + // Skip if at max capacity + if (_tabs.length >= maxTabs) { + break; + } + + // Add tab without ChatStore (lazy loading) + _tabs.add( + ChatTabInfo( + channelId: persisted.channelId, + channelLogin: persisted.channelLogin, + displayName: persisted.displayName, + isPrimary: false, + ), + ); + } + } + + /// Syncs current secondary tabs to settings for persistence. + void _syncSecondaryTabsToSettings() { + if (!settingsStore.persistChatTabs) return; + + // Get all non-primary tabs and convert to PersistedChatTab + final secondaryTabs = _tabs + .where((tab) => !tab.isPrimary) + .map(PersistedChatTab.fromChatTabInfo) + .toList(); + + // Update settings (autorun will handle persistence) + settingsStore.secondaryTabs = secondaryTabs; + } + + /// Adds a new chat tab for the given channel. + /// Returns true if the tab was added, false if at limit or duplicate. + @action + bool addTab({ + required String channelId, + required String channelLogin, + required String displayName, + }) { + // Check if at max capacity + if (_tabs.length >= maxTabs) { + return false; + } + + // Check for duplicate channel + final existingIndex = _tabs.indexWhere((tab) => tab.channelId == channelId); + if (existingIndex != -1) { + // Switch to existing tab instead of adding duplicate + setActiveTab(existingIndex); + return false; + } + + // Add the new tab (lazy - no ChatStore yet) + _tabs.add( + ChatTabInfo( + channelId: channelId, + channelLogin: channelLogin, + displayName: displayName, + isPrimary: false, + ), + ); + + // Switch to the new tab (this will activate it) + final newIndex = _tabs.length - 1; + activateTab(newIndex); + activeTabIndex = newIndex; + + // Sync to settings for persistence + _syncSecondaryTabsToSettings(); + + return true; + } + + /// Removes the tab at the given index. + /// Returns true if removed, false if primary tab or invalid index. + @action + bool removeTab(int index) { + // Validate index + if (index < 0 || index >= _tabs.length) { + return false; + } + + // Cannot remove primary tab + if (_tabs[index].isPrimary) { + return false; + } + + // Dispose the ChatStore before removing (if activated) + _tabs[index].chatStore?.dispose(); + + // Remove the tab + _tabs.removeAt(index); + + // Adjust active index if necessary + if (activeTabIndex >= _tabs.length) { + activeTabIndex = _tabs.length - 1; + } else if (activeTabIndex > index) { + activeTabIndex--; + } + + // Ensure the new active tab is activated (in case it was lazy) + activateTab(activeTabIndex); + + // Sync to settings for persistence + _syncSecondaryTabsToSettings(); + + return true; + } + + /// Reorders a tab from oldIndex to newIndex. + /// The primary tab (index 0) cannot be moved, and no tab can be placed before it. + @action + void reorderTab(int oldIndex, int newIndex) { + // Primary tab (index 0) cannot be moved, and nothing can move before it + if (oldIndex == 0 || newIndex == 0) return; + + // ReorderableListView passes newIndex as if item was already removed + if (oldIndex < newIndex) { + newIndex -= 1; + } + if (oldIndex == newIndex) return; + + final tab = _tabs.removeAt(oldIndex); + _tabs.insert(newIndex, tab); + + // Adjust activeTabIndex to follow the active tab + if (activeTabIndex == oldIndex) { + activeTabIndex = newIndex; + } else if (oldIndex < activeTabIndex && newIndex >= activeTabIndex) { + activeTabIndex -= 1; + } else if (oldIndex > activeTabIndex && newIndex <= activeTabIndex) { + activeTabIndex += 1; + } + + _syncSecondaryTabsToSettings(); + } + + /// Sets the active tab to the given index. + @action + void setActiveTab(int index) { + if (index >= 0 && index < _tabs.length) { + // Clear text input and emote menu when switching tabs + if (index != activeTabIndex) { + final currentStore = _tabs[activeTabIndex].chatStore; + if (currentStore != null) { + // Close emote menu if open + currentStore.assetsStore.showEmoteMenu = false; + // Unfocus input + currentStore.unfocusInput(); + // Clear text input + currentStore.textController.clear(); + // Clear reply state + currentStore.replyingToMessage = null; + } + } + + // Activate the target tab if not already activated + activateTab(index); + + activeTabIndex = index; + } + } + + /// Disposes all ChatStores and cleans up resources. + void dispose() { + for (final tab in _tabs) { + tab.chatStore?.dispose(); + } + _tabs.clear(); + + // Disable wakelock once after all tabs disposed + WakelockPlus.disable(); + } +} diff --git a/lib/screens/channel/chat/stores/chat_tabs_store.g.dart b/lib/screens/channel/chat/stores/chat_tabs_store.g.dart new file mode 100644 index 00000000..1408254f --- /dev/null +++ b/lib/screens/channel/chat/stores/chat_tabs_store.g.dart @@ -0,0 +1,181 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chat_tabs_store.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PersistedChatTab _$PersistedChatTabFromJson(Map json) => + PersistedChatTab( + channelId: json['channelId'] as String, + channelLogin: json['channelLogin'] as String, + displayName: json['displayName'] as String, + ); + +Map _$PersistedChatTabToJson(PersistedChatTab instance) => + { + 'channelId': instance.channelId, + 'channelLogin': instance.channelLogin, + 'displayName': instance.displayName, + }; + +// ************************************************************************** +// StoreGenerator +// ************************************************************************** + +// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers + +mixin _$ChatTabsStore on ChatTabsStoreBase, Store { + Computed? _$activeTabComputed; + + @override + ChatTabInfo get activeTab => (_$activeTabComputed ??= Computed( + () => super.activeTab, + name: 'ChatTabsStoreBase.activeTab', + )).value; + Computed? _$activeChatStoreComputed; + + @override + ChatStore get activeChatStore => + (_$activeChatStoreComputed ??= Computed( + () => super.activeChatStore, + name: 'ChatTabsStoreBase.activeChatStore', + )).value; + Computed? _$canAddTabComputed; + + @override + bool get canAddTab => (_$canAddTabComputed ??= Computed( + () => super.canAddTab, + name: 'ChatTabsStoreBase.canAddTab', + )).value; + Computed? _$showTabBarComputed; + + @override + bool get showTabBar => (_$showTabBarComputed ??= Computed( + () => super.showTabBar, + name: 'ChatTabsStoreBase.showTabBar', + )).value; + + late final _$_tabsAtom = Atom( + name: 'ChatTabsStoreBase._tabs', + context: context, + ); + + ObservableList get tabs { + _$_tabsAtom.reportRead(); + return super._tabs; + } + + @override + ObservableList get _tabs => tabs; + + @override + set _tabs(ObservableList value) { + _$_tabsAtom.reportWrite(value, super._tabs, () { + super._tabs = value; + }); + } + + late final _$activeTabIndexAtom = Atom( + name: 'ChatTabsStoreBase.activeTabIndex', + context: context, + ); + + @override + int get activeTabIndex { + _$activeTabIndexAtom.reportRead(); + return super.activeTabIndex; + } + + @override + set activeTabIndex(int value) { + _$activeTabIndexAtom.reportWrite(value, super.activeTabIndex, () { + super.activeTabIndex = value; + }); + } + + late final _$ChatTabsStoreBaseActionController = ActionController( + name: 'ChatTabsStoreBase', + context: context, + ); + + @override + void activateTab(int index) { + final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction( + name: 'ChatTabsStoreBase.activateTab', + ); + try { + return super.activateTab(index); + } finally { + _$ChatTabsStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + bool addTab({ + required String channelId, + required String channelLogin, + required String displayName, + }) { + final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction( + name: 'ChatTabsStoreBase.addTab', + ); + try { + return super.addTab( + channelId: channelId, + channelLogin: channelLogin, + displayName: displayName, + ); + } finally { + _$ChatTabsStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + bool removeTab(int index) { + final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction( + name: 'ChatTabsStoreBase.removeTab', + ); + try { + return super.removeTab(index); + } finally { + _$ChatTabsStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void reorderTab(int oldIndex, int newIndex) { + final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction( + name: 'ChatTabsStoreBase.reorderTab', + ); + try { + return super.reorderTab(oldIndex, newIndex); + } finally { + _$ChatTabsStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void setActiveTab(int index) { + final _$actionInfo = _$ChatTabsStoreBaseActionController.startAction( + name: 'ChatTabsStoreBase.setActiveTab', + ); + try { + return super.setActiveTab(index); + } finally { + _$ChatTabsStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + String toString() { + return ''' +activeTabIndex: ${activeTabIndex}, +activeTab: ${activeTab}, +activeChatStore: ${activeChatStore}, +canAddTab: ${canAddTab}, +showTabBar: ${showTabBar} + '''; + } +} diff --git a/lib/screens/channel/chat/widgets/add_chat_dialog.dart b/lib/screens/channel/chat/widgets/add_chat_dialog.dart new file mode 100644 index 00000000..cbf9927b --- /dev/null +++ b/lib/screens/channel/chat/widgets/add_chat_dialog.dart @@ -0,0 +1,256 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/models/channel.dart'; +import 'package:frosty/utils.dart'; +import 'package:frosty/utils/modal_bottom_sheet.dart'; +import 'package:frosty/widgets/alert_message.dart'; +import 'package:frosty/widgets/animated_scroll_border.dart'; +import 'package:frosty/widgets/live_indicator.dart'; +import 'package:frosty/widgets/profile_picture.dart'; +import 'package:frosty/widgets/section_header.dart'; +import 'package:frosty/widgets/skeleton_loader.dart'; +import 'package:frosty/widgets/uptime.dart'; + +/// Data class returned when a channel is selected in the AddChatSheet. +class AddChatResult { + final String channelId; + final String channelLogin; + final String displayName; + + const AddChatResult({ + required this.channelId, + required this.channelLogin, + required this.displayName, + }); +} + +/// Bottom sheet for adding a new chat tab by searching for a Twitch channel. +class AddChatSheet extends StatefulWidget { + final TwitchApi twitchApi; + + const AddChatSheet({super.key, required this.twitchApi}); + + /// Shows the bottom sheet and returns the selected channel info, or null if cancelled. + static Future show( + BuildContext context, + TwitchApi twitchApi, + ) { + return showModalBottomSheetWithProperFocus( + context: context, + isScrollControlled: true, + builder: (context) => AddChatSheet(twitchApi: twitchApi), + ); + } + + @override + State createState() => _AddChatSheetState(); +} + +class _AddChatSheetState extends State { + final _textController = TextEditingController(); + final _focusNode = FocusNode(); + final _scrollController = ScrollController(); + + Timer? _debounce; + bool _isLoading = false; + String? _errorMessage; + List _results = []; + + @override + void initState() { + super.initState(); + _textController.addListener(() => setState(() {})); + _focusNode.addListener(() => setState(() {})); + } + + @override + void dispose() { + _debounce?.cancel(); + _textController.dispose(); + _focusNode.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + void _onSearchChanged(String query) { + // Cancel any existing debounce + _debounce?.cancel(); + + if (query.isEmpty) { + setState(() { + _results = []; + _errorMessage = null; + _isLoading = false; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + // Debounce the search by 300ms + _debounce = Timer(const Duration(milliseconds: 300), () async { + try { + final channels = await widget.twitchApi.searchChannels(query: query); + if (mounted) { + setState(() { + _results = channels; + _isLoading = false; + _errorMessage = null; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _results = []; + _isLoading = false; + _errorMessage = 'Failed to search channels'; + }); + } + } + }); + } + + void _selectChannel(ChannelQuery channel) { + Navigator.of(context).pop( + AddChatResult( + channelId: channel.id, + channelLogin: channel.broadcasterLogin, + displayName: channel.displayName, + ), + ); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.8, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionHeader('Add Chat', isFirst: true), + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: TextField( + controller: _textController, + focusNode: _focusNode, + autocorrect: false, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.search_rounded), + hintText: 'Search for a channel', + suffixIcon: + _focusNode.hasFocus || _textController.text.isNotEmpty + ? IconButton( + tooltip: _textController.text.isEmpty + ? 'Cancel' + : 'Clear', + onPressed: () { + if (_textController.text.isEmpty) { + _focusNode.unfocus(); + } + _textController.clear(); + _onSearchChanged(''); + }, + icon: const Icon(Icons.close_rounded), + ) + : null, + ), + onChanged: _onSearchChanged, + textInputAction: TextInputAction.search, + ), + ), + AnimatedScrollBorder(scrollController: _scrollController), + Expanded(child: _buildResults()), + ], + ), + ), + ); + } + + Widget _buildResults() { + if (_textController.text.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + 'Search for a channel to add', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } + + if (_isLoading) { + return ListView.builder( + controller: _scrollController, + itemCount: 8, + itemBuilder: (context, index) => ChannelSkeletonLoader(index: index), + ); + } + + if (_errorMessage != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: AlertMessage(message: _errorMessage!), + ), + ); + } + + if (_results.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + 'No channels found', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } + + return ListView.builder( + controller: _scrollController, + padding: EdgeInsets.zero, + itemCount: _results.length, + itemBuilder: (context, index) { + final channel = _results[index]; + final displayName = getReadableName( + channel.displayName, + channel.broadcasterLogin, + ); + + return ListTile( + leading: ProfilePicture( + userLogin: channel.broadcasterLogin, + radius: 16, + ), + title: Text(displayName), + subtitle: channel.isLive + ? Row( + spacing: 6, + children: [ + const LiveIndicator(), + Uptime(startTime: channel.startedAt), + ], + ) + : null, + onTap: () => _selectChannel(channel), + ); + }, + ); + } +} + +// Keep backward compatibility alias +typedef AddChatDialog = AddChatSheet; diff --git a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart index 10290ae1..ff20e7d3 100644 --- a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart +++ b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart @@ -5,17 +5,28 @@ import 'package:frosty/constants.dart'; import 'package:frosty/models/irc.dart'; import 'package:frosty/screens/channel/chat/details/chat_details.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; -import 'package:frosty/widgets/animated_scroll_border.dart'; -import 'package:frosty/widgets/cached_image.dart'; +import 'package:frosty/utils/context_extensions.dart'; +import 'package:frosty/utils/modal_bottom_sheet.dart'; +import 'package:frosty/widgets/blurred_container.dart'; +import 'package:frosty/widgets/frosty_cached_network_image.dart'; class ChatBottomBar extends StatelessWidget { final ChatStore chatStore; - const ChatBottomBar({super.key, required this.chatStore}); + /// Callback to add a new chat tab. + /// Passes this to ChatDetails to show "Add chat" option. + final VoidCallback onAddChat; + + const ChatBottomBar({ + super.key, + required this.chatStore, + required this.onAddChat, + }); @override Widget build(BuildContext context) { - final isEmotesEnabled = chatStore.settings.showTwitchEmotes || + final isEmotesEnabled = + chatStore.settings.showTwitchEmotes || chatStore.settings.show7TVEmotes || chatStore.settings.showBTTVEmotes || chatStore.settings.showFFZEmotes; @@ -34,7 +45,7 @@ class ChatBottomBar extends StatelessWidget { : Icons.emoji_emotions_outlined, ), onPressed: () { - FocusScope.of(context).unfocus(); + chatStore.unfocusInput(); chatStore.assetsStore.showEmoteMenu = !chatStore.assetsStore.showEmoteMenu; }, @@ -44,18 +55,19 @@ class ChatBottomBar extends StatelessWidget { return Observer( builder: (context) { - final matchingEmotes = [ - ...chatStore.assetsStore.userEmoteToObject.values, - ...chatStore.assetsStore.bttvEmotes, - ...chatStore.assetsStore.ffzEmotes, - ...chatStore.assetsStore.sevenTVEmotes, - ] - .where( - (emote) => emote.name.toLowerCase().contains( + final matchingEmotes = + [ + ...chatStore.assetsStore.userEmoteToObject.values, + ...chatStore.assetsStore.bttvEmotes, + ...chatStore.assetsStore.ffzEmotes, + ...chatStore.assetsStore.sevenTVEmotes, + ] + .where( + (emote) => emote.name.toLowerCase().contains( chatStore.inputText.split(' ').last.toLowerCase(), ), - ) - .toList(); + ) + .toList(); final matchingChatters = chatStore.chatDetailsStore.chatUsers .where( @@ -69,9 +81,17 @@ class ChatBottomBar extends StatelessWidget { ) .toList(); - return Column( + final isFullscreenOverlay = + chatStore.settings.fullScreen && context.isLandscape; + + final hasChatDelay = + chatStore.settings.showVideo && chatStore.settings.chatDelay > 0; + final delayTooltipMessage = + 'Chatting is disabled due to message delay (${chatStore.settings.chatDelay.toInt()}s)'; + const loginTooltipMessage = 'Log in to chat'; + + final bottomBarContent = Column( children: [ - AnimatedScrollBorder(scrollController: chatStore.scrollController), if (chatStore.replyingToMessage != null) ...[ const Divider(), ListTile( @@ -87,16 +107,16 @@ class ChatBottomBar extends StatelessWidget { assetsStore: chatStore.assetsStore, emoteScale: chatStore.settings.emoteScale, badgeScale: chatStore.settings.badgeScale, - useReadableColors: chatStore.settings.useReadableColors, launchExternal: chatStore.settings.launchUrlExternal, timestamp: chatStore.settings.timestampType, + currentChannelId: chatStore.channelId, ), ), maxLines: 3, overflow: TextOverflow.ellipsis, - style: DefaultTextStyle.of(context) - .style - .copyWith(fontSize: chatStore.settings.fontSize), + style: context.defaultTextStyle.copyWith( + fontSize: chatStore.settings.fontSize, + ), ), ), trailing: IconButton( @@ -138,7 +158,8 @@ class ChatBottomBar extends StatelessWidget { child: FrostyCachedNetworkImage( imageUrl: matchingEmotes[index].url, useFade: false, - height: matchingEmotes[index].height?.toDouble() ?? + height: + matchingEmotes[index].height?.toDouble() ?? defaultEmoteSize, width: matchingEmotes[index].width?.toDouble(), ), @@ -170,10 +191,10 @@ class ChatBottomBar extends StatelessWidget { chatStore.textController.text = split.join(' '); chatStore.textController.selection = TextSelection.fromPosition( - TextPosition( - offset: chatStore.textController.text.length, - ), - ); + TextPosition( + offset: chatStore.textController.text.length, + ), + ); }, child: Text(matchingChatters[index]), ), @@ -187,67 +208,136 @@ class ChatBottomBar extends StatelessWidget { if (!chatStore.expandChat && chatStore.settings.chatWidth < 0.3 && chatStore.settings.showVideo && - MediaQuery.of(context).orientation == - Orientation.landscape) - IconButton( - tooltip: 'Enter a message', - onPressed: () { - chatStore.expandChat = true; - chatStore.textFieldFocusNode.requestFocus(); + context.isLandscape) + Builder( + builder: (context) { + final isDisabled = + !chatStore.auth.isLoggedIn || hasChatDelay; + + return GestureDetector( + onTap: isDisabled + ? () { + final message = hasChatDelay + ? delayTooltipMessage + : loginTooltipMessage; + chatStore.updateNotification(message); + } + : null, + child: IconButton( + tooltip: 'Enter a message', + onPressed: isDisabled + ? null + : () { + chatStore.expandChat = true; + chatStore.safeRequestFocus(); + }, + icon: const Icon(Icons.edit), + ), + ); }, - icon: const Icon(Icons.edit), ) else Expanded( - child: TextField( - textInputAction: TextInputAction.send, - focusNode: chatStore.textFieldFocusNode, - minLines: 1, - maxLines: 3, - enabled: chatStore.auth.isLoggedIn ? true : false, - decoration: InputDecoration( - prefixIcon: chatStore.settings.emoteMenuButtonOnLeft - ? emoteMenuButton - : null, - suffixIcon: chatStore.settings.emoteMenuButtonOnLeft - ? null - : emoteMenuButton, - hintMaxLines: 1, - hintText: chatStore.auth.isLoggedIn - ? 'Send a ${chatStore.replyingToMessage != null ? 'reply' : 'message'} ${chatStore.settings.chatDelay == 0 || !chatStore.settings.showVideo ? '' : '(${chatStore.settings.chatDelay.toInt()}s delay)'}' - : 'Log in to chat', - ), - controller: chatStore.textController, - onSubmitted: chatStore.sendMessage, - onTapOutside: (_) { - chatStore.textFieldFocusNode.unfocus(); + child: Observer( + builder: (context) { + final isDisabled = + !chatStore.auth.isLoggedIn || + chatStore.isSendingMessage || + hasChatDelay; + + return GestureDetector( + onTap: isDisabled && !chatStore.isSendingMessage + ? () { + final message = hasChatDelay + ? delayTooltipMessage + : loginTooltipMessage; + chatStore.updateNotification(message); + } + : null, + child: TextField( + textInputAction: TextInputAction.send, + focusNode: chatStore.textFieldFocusNode, + minLines: 1, + maxLines: 3, + // Disable text field when sending message, when not logged in, or when chat delay is active + enabled: !isDisabled, + decoration: InputDecoration( + prefixIcon: + chatStore.settings.emoteMenuButtonOnLeft + ? emoteMenuButton + : null, + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + if (!chatStore + .settings + .emoteMenuButtonOnLeft && + emoteMenuButton != null) + emoteMenuButton, + ], + ), + hintMaxLines: 1, + hintText: chatStore.auth.isLoggedIn + ? chatStore.isSendingMessage + ? 'Sending...' + : chatStore.replyingToMessage != null + ? 'Reply' + : 'Chat' + : loginTooltipMessage, + ), + controller: chatStore.textController, + onSubmitted: chatStore.sendMessage, + onTapOutside: (_) { + chatStore.textFieldFocusNode.unfocus(); + }, + ), + ); }, ), ), if (chatStore.showSendButton && (chatStore.settings.chatWidth >= 0.3 || chatStore.expandChat || - MediaQuery.of(context).orientation == - Orientation.portrait)) - IconButton( - tooltip: 'Send', - icon: const Icon(Icons.send_rounded), - onPressed: chatStore.auth.isLoggedIn - ? () => chatStore - .sendMessage(chatStore.textController.text) - : null, + context.isPortrait)) + Observer( + builder: (context) { + return IconButton( + tooltip: chatStore.isSendingMessage + ? 'Sending...' + : 'Send', + icon: chatStore.isSendingMessage + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.send_rounded), + onPressed: + chatStore.auth.isLoggedIn && + !chatStore.isSendingMessage && + !hasChatDelay + ? () => chatStore.sendMessage( + chatStore.textController.text, + ) + : null, + ); + }, ) else IconButton( icon: Icon(Icons.adaptive.more_rounded), tooltip: 'More', - onPressed: () => showModalBottomSheet( + onPressed: () => showModalBottomSheetWithProperFocus( isScrollControlled: true, context: context, builder: (_) => ChatDetails( chatDetailsStore: chatStore.chatDetailsStore, chatStore: chatStore, userLogin: chatStore.channelName, + onAddChat: onAddChat, ), ), ), @@ -256,6 +346,25 @@ class ChatBottomBar extends StatelessWidget { ), ], ); + + return isFullscreenOverlay + ? Padding( + padding: EdgeInsets.only( + bottom: chatStore.assetsStore.showEmoteMenu + ? 0 + : MediaQuery.of(context).padding.bottom, + ), + child: bottomBarContent, + ) + : BlurredContainer( + gradientDirection: GradientDirection.down, + padding: EdgeInsets.only( + bottom: chatStore.assetsStore.showEmoteMenu + ? 0 + : MediaQuery.of(context).padding.bottom, + ), + child: bottomBarContent, + ); }, ); } diff --git a/lib/screens/channel/chat/widgets/chat_message.dart b/lib/screens/channel/chat/widgets/chat_message.dart index 67a5ebd2..3a940085 100644 --- a/lib/screens/channel/chat/widgets/chat_message.dart +++ b/lib/screens/channel/chat/widgets/chat_message.dart @@ -1,3 +1,4 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; @@ -7,7 +8,7 @@ import 'package:frosty/models/irc.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart'; import 'package:frosty/screens/channel/chat/widgets/reply_thread.dart'; -import 'package:frosty/screens/settings/stores/auth_store.dart'; +import 'package:frosty/utils/modal_bottom_sheet.dart'; import 'package:provider/provider.dart'; class ChatMessage extends StatelessWidget { @@ -15,6 +16,7 @@ class ChatMessage extends StatelessWidget { final ChatStore chatStore; final bool isModal; final bool showReplyHeader; + final bool isInReplyThread; const ChatMessage({ super.key, @@ -22,6 +24,7 @@ class ChatMessage extends StatelessWidget { required this.chatStore, this.isModal = false, this.showReplyHeader = true, + this.isInReplyThread = false, }); void onTapName(BuildContext context) { @@ -34,7 +37,7 @@ class ChatMessage extends StatelessWidget { return; } - showModalBottomSheet( + showModalBottomSheetWithProperFocus( isScrollControlled: true, context: context, builder: (context) => ChatUserModal( @@ -46,23 +49,14 @@ class ChatMessage extends StatelessWidget { ); } - void onTapPingedUser( - BuildContext context, { - required String nickname, - }) { + void onTapPingedUser(BuildContext context, {required String nickname}) { // Ignore if the message is a recent message in the modal bottom sheet. if (isModal) return; final twitchApi = context.read(); - final authStore = context.read(); - twitchApi - .getUser( - headers: authStore.headersTwitch, - userLogin: nickname, - ) - .then((user) { + twitchApi.getUser(userLogin: nickname).then((user) { if (context.mounted) { - showModalBottomSheet( + showModalBottomSheetWithProperFocus( isScrollControlled: true, context: context, builder: (context) => ChatUserModal( @@ -91,31 +85,33 @@ class ChatMessage extends StatelessWidget { return; } - showModalBottomSheet( + showModalBottomSheetWithProperFocus( context: context, isScrollControlled: true, builder: (context) => ListView( shrinkWrap: true, primary: false, children: [ - ListTile( - title: Text.rich( + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text.rich( TextSpan( children: ircMessage.generateSpan( context, assetsStore: chatStore.assetsStore, emoteScale: chatStore.settings.emoteScale, badgeScale: chatStore.settings.badgeScale, - useReadableColors: chatStore.settings.useReadableColors, launchExternal: chatStore.settings.launchUrlExternal, timestamp: chatStore.settings.timestampType, channelIdToUserTwitch: chatStore.assetsStore.channelIdToUserTwitch, + currentChannelId: chatStore.channelId, ), style: defaultTextStyle, ), ), ), + const Divider(indent: 12, endIndent: 12), ListTile( onTap: () { copyMessage(); @@ -124,10 +120,38 @@ class ChatMessage extends StatelessWidget { leading: const Icon(Icons.copy), title: const Text('Copy message'), ), + ListTile( + onTap: () async { + await copyMessage(); + + final hasChatDelay = + chatStore.settings.showVideo && + chatStore.settings.chatDelay > 0; + + if (hasChatDelay) { + chatStore.updateNotification( + 'Chatting is disabled due to message delay (${chatStore.settings.chatDelay.toInt()}s)', + ); + if (context.mounted) { + Navigator.pop(context); + } + return; + } + + // Paste the copied message into the text controller + chatStore.textController.text = ircMessage.message ?? ''; + chatStore.safeRequestFocus(); + if (context.mounted) { + Navigator.pop(context); + } + }, + leading: const Icon(Icons.content_paste), + title: const Text('Copy message and paste'), + ), ListTile( onTap: () { chatStore.replyingToMessage = ircMessage; - chatStore.textFieldFocusNode.requestFocus(); + chatStore.safeRequestFocus(); Navigator.pop(context); }, leading: const Icon(Icons.reply), @@ -143,8 +167,9 @@ class ChatMessage extends StatelessWidget { final defaultTextStyle = DefaultTextStyle.of(context).style; final messageHeaderIconSize = defaultBadgeSize * chatStore.settings.badgeScale; - final messageHeaderTextColor = - defaultTextStyle.color?.withValues(alpha: 0.5); + final messageHeaderTextColor = defaultTextStyle.color?.withValues( + alpha: 0.5, + ); const messageHeaderFontWeight = FontWeight.w600; return Observer( @@ -157,8 +182,9 @@ class ChatMessage extends StatelessWidget { case Command.userState: final shouldHighlightFirstMessage = chatStore.settings.highlightFirstTimeChatter && - ircMessage.tags['first-msg'] == '1'; - final shouldHighlightMessage = chatStore.settings.showUserNotices && + ircMessage.tags['first-msg'] == '1'; + final shouldHighlightMessage = + chatStore.settings.showUserNotices && ircMessage.tags['msg-id'] == 'highlighted-message'; final messageSpan = Text.rich( @@ -172,11 +198,11 @@ class ChatMessage extends StatelessWidget { assetsStore: chatStore.assetsStore, emoteScale: chatStore.settings.emoteScale, badgeScale: chatStore.settings.badgeScale, - useReadableColors: chatStore.settings.useReadableColors, launchExternal: chatStore.settings.launchUrlExternal, timestamp: chatStore.settings.timestampType, channelIdToUserTwitch: chatStore.assetsStore.channelIdToUserTwitch, + currentChannelId: chatStore.channelId, ), ), ); @@ -196,21 +222,32 @@ class ChatMessage extends StatelessWidget { messageHeader = GestureDetector( onTap: isModal ? null - : () => showModalBottomSheet( - context: context, - builder: (context) { - return ReplyThread( - selectedMessage: ircMessage, - chatStore: chatStore, - ); - }, - ), - child: Text( - 'Replying to @$replyUser: $replyBody', + : () => showModalBottomSheetWithProperFocus( + context: context, + builder: (context) { + return ReplyThread( + selectedMessage: ircMessage, + chatStore: chatStore, + ); + }, + ), + child: Text.rich( maxLines: 1, - style: TextStyle( - overflow: TextOverflow.ellipsis, - color: messageHeaderTextColor, + overflow: TextOverflow.ellipsis, + TextSpan( + children: [ + TextSpan( + text: replyUser, + style: TextStyle( + fontWeight: FontWeight.w600, + color: messageHeaderTextColor, + ), + ), + TextSpan( + text: ': $replyBody', + style: TextStyle(color: messageHeaderTextColor), + ), + ], ), ), ); @@ -245,20 +282,18 @@ class ChatMessage extends StatelessWidget { if (messageHeader != null) { renderMessage = Column( crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, children: [ if (messageHeaderIcon != null) Row( + spacing: 4, children: [ messageHeaderIcon, - const SizedBox(width: 4), - Flexible( - child: messageHeader, - ), + Flexible(child: messageHeader), ], ) else messageHeader, - const SizedBox(height: 4), messageSpan, ], ); @@ -276,6 +311,7 @@ class ChatMessage extends StatelessWidget { opacity: 0.5, child: Column( crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, children: [ if (banDuration == null) if (ircMessage.command == Command.clearMessage) @@ -291,10 +327,10 @@ class ChatMessage extends StatelessWidget { else Text( 'Timed out for $banDuration ${int.parse(banDuration) > 1 ? 'seconds' : 'second'}', - style: - const TextStyle(fontWeight: messageHeaderFontWeight), + style: const TextStyle( + fontWeight: messageHeaderFontWeight, + ), ), - const SizedBox(height: 4), Text.rich( TextSpan( children: ircMessage.generateSpan( @@ -304,12 +340,21 @@ class ChatMessage extends StatelessWidget { assetsStore: chatStore.assetsStore, emoteScale: chatStore.settings.emoteScale, badgeScale: chatStore.settings.badgeScale, - showMessage: chatStore.settings.showDeletedMessages, - useReadableColors: chatStore.settings.useReadableColors, + showMessage: + chatStore.settings.showDeletedMessages || + chatStore.revealedMessageIds.contains( + ircMessage.tags['id'], + ), + onTapDeletedMessage: () { + if (ircMessage.tags['id'] != null) { + chatStore.revealMessage(ircMessage.tags['id']!); + } + }, launchExternal: chatStore.settings.launchUrlExternal, timestamp: chatStore.settings.timestampType, channelIdToUserTwitch: chatStore.assetsStore.channelIdToUserTwitch, + currentChannelId: chatStore.channelId, ), ), ), @@ -318,10 +363,36 @@ class ChatMessage extends StatelessWidget { ); break; case Command.notice: - renderMessage = Text.rich( - TextSpan(text: ircMessage.message), - style: TextStyle(color: messageHeaderTextColor), - ); + if (ircMessage.actionCallback != null && + ircMessage.actionLabel != null) { + renderMessage = Text.rich( + TextSpan( + children: [ + TextSpan( + text: ircMessage.message ?? '', + style: TextStyle(color: messageHeaderTextColor), + ), + TextSpan(text: ' '), + TextSpan( + text: ircMessage.actionLabel!, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w600, + decoration: TextDecoration.underline, + decorationColor: Theme.of(context).colorScheme.primary, + ), + recognizer: TapGestureRecognizer() + ..onTap = ircMessage.actionCallback, + ), + ], + ), + ); + } else { + renderMessage = Text.rich( + TextSpan(text: ircMessage.message), + style: TextStyle(color: messageHeaderTextColor), + ); + } break; case Command.userNotice: if (chatStore.settings.showUserNotices) { @@ -334,8 +405,8 @@ class ChatMessage extends StatelessWidget { final isGift = messageId?.contains('gift') == true; final isSub = messageId?.contains('sub') == true; final isRaid = messageId?.contains('raid') == true; - // TODO: Implement Prime sub icons when a crown icon is added. - // final isPrime = ircMessage.tags['msg-param-sub-plan'] == 'Prime'; + final isPrime = + ircMessage.tags['msg-param-sub-plan'] == 'Prime'; IconData? icon; if (isGift) { @@ -344,6 +415,8 @@ class ChatMessage extends StatelessWidget { icon = Icons.star_rounded; } else if (isRaid) { icon = Icons.people_rounded; + } else if (isPrime) { + icon = Icons.workspace_premium_rounded; } if (icon != null) { @@ -374,22 +447,18 @@ class ChatMessage extends StatelessWidget { renderMessage = Column( crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, children: [ if (messageHeader != null) Row( crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, children: [ - if (messageHeaderIcon != null) ...[ - messageHeaderIcon, - const SizedBox(width: 4), - ], - Expanded( - child: messageHeader, - ), + if (messageHeaderIcon != null) ...[messageHeaderIcon], + Expanded(child: messageHeader), ], ), if (ircMessage.message != null) ...[ - const SizedBox(height: 4), Text.rich( TextSpan( children: ircMessage.generateSpan( @@ -399,12 +468,11 @@ class ChatMessage extends StatelessWidget { assetsStore: chatStore.assetsStore, emoteScale: chatStore.settings.emoteScale, badgeScale: chatStore.settings.badgeScale, - useReadableColors: - chatStore.settings.useReadableColors, launchExternal: chatStore.settings.launchUrlExternal, timestamp: chatStore.settings.timestampType, channelIdToUserTwitch: chatStore.assetsStore.channelIdToUserTwitch, + currentChannelId: chatStore.channelId, ), ), ), @@ -419,22 +487,45 @@ class ChatMessage extends StatelessWidget { renderMessage = const SizedBox(); } + // Check if this is a reply message in reply thread context for indentation + final isReplyInThread = + isInReplyThread && + ircMessage.tags['reply-parent-display-name'] != null && + ircMessage.tags['reply-parent-msg-body'] != null; + + // Add reply icon for messages in reply thread + final messageWithIcon = isReplyInThread + ? Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(right: 8.0, top: 2.0), + child: Icon( + Icons.subdirectory_arrow_right_rounded, + size: 16, + color: messageHeaderTextColor, + ), + ), + Expanded(child: renderMessage), + ], + ) + : renderMessage; + final paddedMessage = Padding( - padding: EdgeInsets.symmetric( - vertical: chatStore.settings.messageSpacing / 2, - horizontal: highlightColor == null ? 12 : 0, + padding: EdgeInsets.only( + top: chatStore.settings.messageSpacing / 2, + bottom: chatStore.settings.messageSpacing / 2, + left: isReplyInThread ? 16 : (highlightColor == null ? 12 : 0), + right: highlightColor == null ? 12 : 0, ), - child: renderMessage, + child: messageWithIcon, ); // Add a divider above the message if dividers are enabled. final dividedMessage = chatStore.settings.showChatMessageDividers ? Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ - paddedMessage, - const Divider(), - ], + children: [paddedMessage, const Divider()], ) : paddedMessage; diff --git a/lib/screens/channel/chat/widgets/chat_tabs.dart b/lib/screens/channel/chat/widgets/chat_tabs.dart new file mode 100644 index 00000000..274fe808 --- /dev/null +++ b/lib/screens/channel/chat/widgets/chat_tabs.dart @@ -0,0 +1,216 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/screens/channel/chat/chat.dart'; +import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart'; +import 'package:frosty/screens/channel/chat/widgets/add_chat_dialog.dart'; +import 'package:frosty/utils.dart'; +import 'package:frosty/widgets/frosty_dialog.dart'; +import 'package:frosty/widgets/profile_picture.dart'; + +/// Widget that displays multiple chat tabs with a tab bar. +/// Wraps the existing Chat widget and manages tab switching. +class ChatTabs extends StatelessWidget { + final ChatTabsStore chatTabsStore; + final EdgeInsetsGeometry? listPadding; + + const ChatTabs({super.key, required this.chatTabsStore, this.listPadding}); + + Future _handleAddChat(BuildContext context) async { + if (!chatTabsStore.canAddTab) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Maximum 10 chats open'), + duration: Duration(seconds: 2), + ), + ); + return; + } + + final result = await AddChatSheet.show(context, chatTabsStore.twitchApi); + + if (result != null) { + final added = chatTabsStore.addTab( + channelId: result.channelId, + channelLogin: result.channelLogin, + displayName: result.displayName, + ); + + if (!added && context.mounted) { + // If not added, it means the channel already exists (switched to it) + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Channel already open, switched to it'), + duration: Duration(seconds: 2), + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Observer( + builder: (context) { + final tabs = chatTabsStore.tabs; + final activeIndex = chatTabsStore.activeTabIndex; + final showTabBar = chatTabsStore.showTabBar; + + // Calculate extra top padding for tab bar when visible + final tabBarHeight = showTabBar ? 48.0 : 0.0; + + // Get the top inset from listPadding (e.g., for AppBar in chat-only mode) + final topInset = listPadding?.resolve(TextDirection.ltr).top ?? 0; + + // Adjust list padding to account for tab bar + final adjustedPadding = listPadding != null + ? listPadding!.add(EdgeInsets.only(top: tabBarHeight)) + : EdgeInsets.only(top: tabBarHeight); + + return PopScope( + canPop: Platform.isIOS, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + + // If pressing the back button on Android while the emote menu + // is open, close it instead of going back to the streams list. + final activeStore = chatTabsStore.activeChatStore; + if (activeStore.assetsStore.showEmoteMenu) { + activeStore.assetsStore.showEmoteMenu = false; + } else { + Navigator.of(context).pop(); + } + }, + child: Stack( + children: [ + // Chat content with IndexedStack to preserve state + Positioned.fill( + child: IndexedStack( + index: activeIndex, + children: tabs.map((tabInfo) { + // Show placeholder for non-activated tabs + if (tabInfo.chatStore == null) { + return const SizedBox.shrink(); + } + return Chat( + key: ValueKey(tabInfo.channelId), + chatStore: tabInfo.chatStore!, + listPadding: adjustedPadding, + onAddChat: () => _handleAddChat(context), + ); + }).toList(), + ), + ), + // Tab bar (only visible when more than 1 tab) + if (showTabBar) + Positioned( + top: topInset, + left: 0, + right: 0, + child: SizedBox( + height: 48, + child: ReorderableListView.builder( + scrollDirection: Axis.horizontal, + itemCount: tabs.length, + padding: const EdgeInsets.symmetric(horizontal: 12), + onReorder: (oldIndex, newIndex) { + HapticFeedback.lightImpact(); + chatTabsStore.reorderTab(oldIndex, newIndex); + }, + proxyDecorator: (child, index, animation) { + return Material( + color: Colors.transparent, + child: child, + ); + }, + itemBuilder: (context, index) { + final tabInfo = chatTabsStore.tabs[index]; + return Padding( + key: ValueKey(tabInfo.channelId), + padding: EdgeInsets.only( + right: index < tabs.length - 1 ? 4 : 0, + ), + child: _buildTab(context, index), + ); + }, + ), + ), + ), + ], + ), + ); + }, + ); + } + + Future _confirmRemoveTab( + BuildContext context, + int index, + String displayName, + ) async { + final confirmed = await showDialog( + context: context, + builder: (context) => FrostyDialog( + title: 'Remove $displayName', + message: "Are you sure you want to remove $displayName's chat tab?", + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Remove'), + ), + ], + ), + ); + + if (confirmed == true) { + HapticFeedback.lightImpact(); + chatTabsStore.removeTab(index); + } + } + + Widget _buildTab(BuildContext context, int index) { + final tabInfo = chatTabsStore.tabs[index]; + final isActive = index == chatTabsStore.activeTabIndex; + final isActivated = tabInfo.isActivated; + final displayName = getReadableName( + tabInfo.displayName, + tabInfo.channelLogin, + ); + + final avatar = ProfilePicture(userLogin: tabInfo.channelLogin, radius: 12); + + return InputChip( + avatar: isActivated ? avatar : Opacity(opacity: 0.5, child: avatar), + label: Text( + displayName, + style: isActivated + ? null + : TextStyle( + color: Theme.of( + context, + ).textTheme.bodyMedium?.color?.withValues(alpha: 0.5), + ), + ), + selected: isActive, + showCheckmark: false, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onPressed: () { + if (!isActive) { + HapticFeedback.selectionClick(); + chatTabsStore.setActiveTab(index); + } + }, + onDeleted: tabInfo.isPrimary + ? null + : () => _confirmRemoveTab(context, index, displayName), + deleteButtonTooltipMessage: 'Close chat', + ); + } +} diff --git a/lib/screens/channel/chat/widgets/chat_user_modal.dart b/lib/screens/channel/chat/widgets/chat_user_modal.dart index 715c0a23..2e0700ae 100644 --- a/lib/screens/channel/chat/widgets/chat_user_modal.dart +++ b/lib/screens/channel/chat/widgets/chat_user_modal.dart @@ -3,7 +3,9 @@ import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_message.dart'; import 'package:frosty/utils.dart'; +import 'package:frosty/utils/modal_bottom_sheet.dart'; import 'package:frosty/widgets/alert_message.dart'; +import 'package:frosty/widgets/frosty_scrollbar.dart'; import 'package:frosty/widgets/profile_picture.dart'; import 'package:frosty/widgets/user_actions_modal.dart'; @@ -36,10 +38,8 @@ class _ChatUserModalState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ ListTile( - contentPadding: const EdgeInsets.all(12), - leading: ProfilePicture( - userLogin: widget.username, - ), + contentPadding: const EdgeInsets.fromLTRB(12, 0, 12, 0), + leading: ProfilePicture(userLogin: widget.username), title: Row( children: [ Flexible( @@ -64,13 +64,13 @@ class _ChatUserModalState extends State { widget.chatStore.textController.text = '@${widget.username} '; Navigator.pop(context); - widget.chatStore.textFieldFocusNode.requestFocus(); + widget.chatStore.safeRequestFocus(); }, icon: const Icon(Icons.reply_rounded), ), IconButton( tooltip: 'More', - onPressed: () => showModalBottomSheet( + onPressed: () => showModalBottomSheetWithProperFocus( context: context, builder: (context) => UserActionsModal( authStore: widget.chatStore.auth, @@ -84,7 +84,7 @@ class _ChatUserModalState extends State { ], ), ), - const Divider(), + const Divider(indent: 12, endIndent: 12), Expanded( child: Observer( builder: (context) { @@ -103,17 +103,19 @@ class _ChatUserModalState extends State { ), ), child: DefaultTextStyle( - style: DefaultTextStyle.of(context) - .style - .copyWith(fontSize: widget.chatStore.settings.fontSize), - child: ListView.builder( - reverse: true, - primary: false, - itemCount: userMessages.length, - itemBuilder: (context, index) => ChatMessage( - ircMessage: userMessages[index], - chatStore: widget.chatStore, - isModal: true, + style: DefaultTextStyle.of(context).style.copyWith( + fontSize: widget.chatStore.settings.fontSize, + ), + child: FrostyScrollbar( + child: ListView.builder( + reverse: true, + primary: false, + itemCount: userMessages.length, + itemBuilder: (context, index) => ChatMessage( + ircMessage: userMessages[index], + chatStore: widget.chatStore, + isModal: true, + ), ), ), ), diff --git a/lib/screens/channel/chat/widgets/reply_thread.dart b/lib/screens/channel/chat/widgets/reply_thread.dart index 85d15083..734408b3 100644 --- a/lib/screens/channel/chat/widgets/reply_thread.dart +++ b/lib/screens/channel/chat/widgets/reply_thread.dart @@ -5,6 +5,7 @@ import 'package:frosty/models/irc.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_message.dart'; import 'package:frosty/utils.dart'; +import 'package:frosty/widgets/frosty_scrollbar.dart'; import 'package:frosty/widgets/section_header.dart'; class ReplyThread extends StatelessWidget { @@ -41,9 +42,9 @@ class ReplyThread extends StatelessWidget { textScaler: TextScaler.linear(chatStore.settings.messageScale), ), child: DefaultTextStyle( - style: DefaultTextStyle.of(context) - .style - .copyWith(fontSize: chatStore.settings.fontSize), + style: DefaultTextStyle.of( + context, + ).style.copyWith(fontSize: chatStore.settings.fontSize), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, @@ -54,39 +55,36 @@ class ReplyThread extends StatelessWidget { padding: EdgeInsets.fromLTRB(12, 0, 12, 8), ), if (replyParent != null) - ChatMessage( - ircMessage: replyParent, - chatStore: chatStore, - ) + ChatMessage(ircMessage: replyParent, chatStore: chatStore) else Padding( padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), child: Text( '$replyName: $replyBody', - style: const TextStyle( - fontWeight: FontWeight.w500, - ), + style: const TextStyle(fontWeight: FontWeight.w500), ), ), - const Divider(), Flexible( - child: ListView( - primary: false, - children: chatStore.messages - .where( - (message) => - message.tags['reply-parent-msg-id'] == - selectedMessage.tags['reply-parent-msg-id'], - ) - .map( - (message) => ChatMessage( - isModal: true, - showReplyHeader: false, - ircMessage: message, - chatStore: chatStore, - ), - ) - .toList(), + child: FrostyScrollbar( + child: ListView( + primary: false, + children: chatStore.messages + .where( + (message) => + message.tags['reply-parent-msg-id'] == + selectedMessage.tags['reply-parent-msg-id'], + ) + .map( + (message) => ChatMessage( + isModal: true, + showReplyHeader: false, + isInReplyThread: true, + ircMessage: message, + chatStore: chatStore, + ), + ) + .toList(), + ), ), ), ], diff --git a/lib/screens/channel/video/stream_info_bar.dart b/lib/screens/channel/video/stream_info_bar.dart new file mode 100644 index 00000000..984ccf49 --- /dev/null +++ b/lib/screens/channel/video/stream_info_bar.dart @@ -0,0 +1,349 @@ +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/utils.dart'; +import 'package:frosty/utils/context_extensions.dart'; +import 'package:frosty/widgets/live_indicator.dart'; +import 'package:frosty/widgets/profile_picture.dart'; +import 'package:frosty/widgets/uptime.dart'; +import 'package:intl/intl.dart'; + +class StreamInfoBar extends StatelessWidget { + final StreamTwitch? streamInfo; + final Channel? offlineChannelInfo; + final bool showCategory; + final bool tappableCategory; + final bool showUptime; + final bool showViewerCount; + final bool showOfflineIndicator; + final EdgeInsets padding; + final TooltipTriggerMode tooltipTriggerMode; + final Color? textColor; + final bool isCompact; + final bool isInSharedChatMode; + final bool isOffline; + final bool showTextShadows; + final String? displayName; + + const StreamInfoBar({ + super.key, + this.streamInfo, + this.offlineChannelInfo, + this.showCategory = true, + this.tappableCategory = true, + this.showUptime = true, + this.showViewerCount = true, + this.showOfflineIndicator = true, + this.padding = EdgeInsets.zero, + this.tooltipTriggerMode = TooltipTriggerMode.tap, + this.textColor, + this.isCompact = false, + this.isInSharedChatMode = false, + this.isOffline = false, + this.showTextShadows = true, + 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, + double fontSize, + FontWeight fontWeight, + ) { + return context.textTheme.bodyMedium?.copyWith( + fontSize: fontSize, + fontWeight: fontWeight, + color: textColor, + shadows: showTextShadows ? _textShadow : null, + ) ?? + const TextStyle(); + } + + TextStyle _getSecondaryTextStyle(BuildContext context, double fontSize) { + return context.textTheme.bodyMedium?.copyWith( + fontSize: fontSize, + fontWeight: FontWeight.w500, + color: + textColor?.withValues(alpha: 0.7) ?? + context.bodySmallColor?.withValues(alpha: 0.7), + shadows: showTextShadows ? _textShadow : null, + ) ?? + const TextStyle(); + } + + @override + Widget build(BuildContext context) { + final streamTitle = isOffline + ? (offlineChannelInfo?.title.trim() ?? '') + : (streamInfo?.title.trim() ?? ''); + final streamerName = isOffline + ? getReadableName( + offlineChannelInfo?.broadcasterName.isNotEmpty == true + ? offlineChannelInfo?.broadcasterName ?? '' + : displayName ?? '', + offlineChannelInfo?.broadcasterLogin ?? '', + ) + : getReadableName( + streamInfo?.userName ?? '', + streamInfo?.userLogin ?? '', + ); + final secondLineSize = isCompact ? 13.0 : 14.0; + + return Padding( + padding: padding, + child: Row( + spacing: 8, + children: [ + Container( + decoration: isInSharedChatMode + ? BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: Theme.of(context).colorScheme.primary, + width: 2, + ), + ) + : null, + child: Padding( + padding: isInSharedChatMode + ? const EdgeInsets.all(1.5) + : EdgeInsets.zero, + child: ProfilePicture( + userLogin: isOffline + ? (offlineChannelInfo?.broadcasterLogin.isNotEmpty == true + ? offlineChannelInfo?.broadcasterLogin ?? '' + : displayName ?? '') + : (streamInfo?.userLogin ?? ''), + radius: 16, + ), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Top row: Streamer name + stream title + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + spacing: 4, + children: [ + Tooltip( + message: streamerName, + triggerMode: tooltipTriggerMode, + child: Text( + streamerName, + style: _getBaseTextStyle(context, 14, FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), + ), + if (streamTitle.isNotEmpty) ...[ + Flexible( + child: Tooltip( + message: streamTitle, + triggerMode: tooltipTriggerMode, + child: Text( + streamTitle, + style: _getSecondaryTextStyle(context, 14), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ), + ], + ], + ), + // Bottom row: Live indicator, uptime, viewer count, game name or Offline text + if (isOffline || + (!isOffline && showUptime) || + (!isOffline && showViewerCount) || + (showCategory && + (isOffline + ? (offlineChannelInfo?.gameName.isNotEmpty ?? false) + : (streamInfo?.gameName.isNotEmpty ?? false)))) ...[ + Row( + children: [ + if (isOffline && showOfflineIndicator) ...[ + Text( + 'Offline', + style: _getSecondaryTextStyle( + context, + secondLineSize, + ), + ), + ], + if (isOffline && + showCategory && + (offlineChannelInfo?.gameName.isNotEmpty ?? + false)) ...[ + if (showOfflineIndicator) const SizedBox(width: 8), + Icon( + Icons.gamepad, + size: secondLineSize, + color: (textColor ?? context.bodySmallColor) + ?.withValues(alpha: 0.7), + shadows: _iconShadow, + ), + const SizedBox(width: 4), + Flexible( + child: Tooltip( + message: offlineChannelInfo?.gameName ?? '', + triggerMode: tooltipTriggerMode, + child: tappableCategory + ? GestureDetector( + onDoubleTap: () { + if (offlineChannelInfo + ?.gameId + .isNotEmpty ?? + false) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + CategoryStreams( + categoryId: + offlineChannelInfo + ?.gameId ?? + '', + ), + ), + ); + } + }, + child: Text( + offlineChannelInfo?.gameName ?? '', + style: _getSecondaryTextStyle( + context, + secondLineSize, + ), + overflow: TextOverflow.ellipsis, + ), + ) + : Text( + offlineChannelInfo?.gameName ?? '', + style: _getSecondaryTextStyle( + context, + secondLineSize, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ] else ...[ + if (!isOffline && (showUptime || showViewerCount)) ...[ + const LiveIndicator(), + const SizedBox(width: 6), + ], + if (!isOffline && showUptime) ...[ + Uptime( + startTime: + streamInfo?.startedAt ?? + DateTime.now().toIso8601String(), + style: _getBaseTextStyle( + context, + secondLineSize, + FontWeight.w500, + ), + ), + if (!isOffline && showViewerCount) + const SizedBox(width: 8), + ], + if (!isOffline && showViewerCount) ...[ + Icon( + Icons.visibility, + size: secondLineSize, + color: textColor ?? context.bodySmallColor, + shadows: _iconShadow, + ), + const SizedBox(width: 4), + Text( + NumberFormat().format(streamInfo?.viewerCount ?? 0), + style: _getBaseTextStyle( + context, + secondLineSize, + FontWeight.w500, + ), + ), + ], + if (!isOffline && + showCategory && + (streamInfo?.gameName.isNotEmpty ?? false) && + (showUptime || showViewerCount)) ...[ + const SizedBox(width: 8), + ], + if (!isOffline && + showCategory && + (streamInfo?.gameName.isNotEmpty ?? false)) ...[ + Icon( + Icons.gamepad, + size: secondLineSize, + color: textColor ?? context.bodySmallColor, + shadows: _iconShadow, + ), + const SizedBox(width: 4), + Flexible( + child: Tooltip( + message: streamInfo?.gameName ?? '', + triggerMode: tooltipTriggerMode, + child: tappableCategory + ? GestureDetector( + onDoubleTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CategoryStreams( + categoryId: + streamInfo?.gameId ?? '', + ), + ), + ), + child: Text( + streamInfo?.gameName ?? '', + style: _getBaseTextStyle( + context, + secondLineSize, + FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ) + : Text( + streamInfo?.gameName ?? '', + style: _getBaseTextStyle( + context, + secondLineSize, + FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ], + ], + ), + ], + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/channel/video/video.dart b/lib/screens/channel/video/video.dart index 4ae568f0..8d99146b 100644 --- a/lib/screens/channel/video/video.dart +++ b/lib/screens/channel/video/video.dart @@ -10,10 +10,7 @@ import 'package:webview_flutter_android/webview_flutter_android.dart'; class Video extends StatefulWidget { final VideoStore videoStore; - const Video({ - super.key, - required this.videoStore, - }); + const Video({super.key, required this.videoStore}); @override State