v5 (#463)
* migrate to dio * ignore folders * simplify twitch api headers handling * clean up main * simplify logs * optimize profile picture fetching fixes refetches on tab switch * update claude md * refactor irc.dart * update copilot instructions with essential commands and project structure details * implement message sending state management and UI updates * refactor notification handling and add copy-paste functionality in chat * refactor AlertMessage to support vertical layout and optional padding * refactor error messages for consistency and clarity across various components * refactor chat layout to use draggable divider and improve chat width settings * refactor Uptime widget to use SharedTimerService for synchronized updates * refactor search results components to handle null future states gracefully * refactor loading indicators to use skeleton loaders for improved user experience * refactor UI components for improved layout and responsiveness, enhance skeleton loaders, and streamline padding adjustments * refactor: replace manual blur effects with BlurredContainer for consistency and reusability * refactor: enhance JavaScript execution and error handling in WebView components * refactor: replace app bar with blurred container for improved UI consistency across screens * refactor: streamline video chat layout and enhance PiP drag gesture functionality * revert some app bar changes * refactor: implement Picture-in-Picture drag functionality in VideoChat * refactor: pass currentChannelId to IRCMessage and ChatMessage for improved context * fix large thubmnail width * chore: update Gradle and Kotlin plugin versions for improved compatibility * changes from v4.10.0 branch * feat: integrate AnimatedScrollBorder for improved UI responsiveness across various screens * refactor: update SectionHeader styling and improve theme management * feat: add pinned section to StreamsList and enhance skeleton loader padding * feat: add repository guidelines for project structure, coding style, testing, and security * feat: enhance StreamCard UI with BlurredContainer and adjust border width in theme * feat: implement unfocusInput and safeRequestFocus methods to manage text field focus behavior * feat: enhance Picture-in-Picture drag interactions with haptic feedback and smooth animations * refactor: improve code formatting and readability across multiple files * feat: implement picture-in-picture mode toggle and state management for video playback * feat: reset PiP mode state when pausing video playback * feat: add custom app bar with profile picture and stream info for video chat * feat: convert multiple settings screens to use StatefulWidget and add animated scroll borders * feat: update blur configuration for adaptive alpha based on theme * fix: adjust default font size in SectionHeader widget * feat: enhance PiP mode with drag overlay instruction and UI improvements * fix: improve badge rendering with consistent margin and border for current channel * refactor: replace BackdropFilter with BlurredContainer for improved clarity and maintainability * fix: reduce background transparency in Uptime widget for better visibility * feat: enhance photo view dialog with full resolution toggle and blurred background * fix: reset chat delay to 0 when auto sync is enabled and update settings title for clarity * fix: adjust border width in theme to improve consistency * feat: add forceDarkMode option to BlurredContainer for improved theme adaptability * feat: implement offline followed channels feature with UI integration and settings option * fix: increase font size and weight for offline status text for better readability * feat: remove offline pinned channels feature and update related UI components * feat: optimize offline channels loading behavior based on expansion state * fix: improve clarity and consistency in repository guidelines formatting * feat: update full resolution URL handling and button visibility logic * feat: replace app bar with blurred version in chat-only mode and add animated scroll border * feat: refactor video chat layout for improved chat and video display in landscape mode * feat: remove bottom bar visibility setting and adjust chat layout accordingly * fix: simplify condition for displaying offline channels section in following tab * feat: enhance reply thread display with indentation and reply icon * feat: implement showModalBottomSheetWithProperFocus for improved focus handling * feat: add gradient direction support to BlurredContainer for enhanced visual effects * feat: enhance NavigationBar and Theme settings for improved visual consistency * feat: add gradient direction support to BlurredContainer in VideoChat for enhanced visual effects * chore: update .gitignore to include .cursor file for better environment management * fix: update PiP drag gesture conditions to include video paused state for better user experience * refactor: replace ScrollController with SettingsPageLayout in settings screens for improved layout management * refactor: centralize orientation handling by introducing OrientationUtils for improved readability and maintainability * refactor: replace OrientationUtils with ContextExtensions for streamlined context-based access to theme, layout, and API dependencies * refactor: improve video chat UI by adjusting title layout and adding stream info tooltips for better user experience * refactor: adjust titleSpacing in theme and navigation components for improved layout consistency * refactor: update stream info timer handling and overlay management for improved UI responsiveness * refactor: enhance stream info timer logic and overlay visibility management for chat-only mode * refactor: enhance input decoration theme in FrostyThemes for improved visual consistency and responsiveness * refactor: adjust inputDecorationTheme padding in FrostyThemes for improved visual consistency * refactor: enhance video overlay management by including video preview overlay in hideElements for improved UI consistency * refactor: improve chat bottom bar padding and video chat layout for enhanced UI consistency * refactor: adjust chat padding to account for device bottom insets for improved layout consistency * refactor: update text styles in FrostyThemes for improved visual consistency across UI components * refactor: update iOS minimum deployment target to 13.0 for improved compatibility * refactor: adjust chat bottom padding based on emote menu visibility for improved layout consistency * refactor: adjust padding values in StreamsList for improved layout consistency * refactor: replace Padding with AnimatedPadding in Chat for smoother layout transitions refactor: replace Container with AnimatedContainer in BlurredContainer for smoother animations * refactor: remove unnecessary radius property from ProfilePicture in OfflineChannelCard for cleaner code * refactor: enhance chat bottom bar suffix icon to display message delay with tooltip for improved user experience * refactor: update hint text and tooltips in chat bottom bar and search for improved clarity * refactor: simplify notification display logic and enhance chat input behavior for improved user experience * refactor: enhance layout consistency by adjusting spacing in various components across the channel and home screens * refactor: introduce LiveIndicator widget and adjust spacing in channel and video components for improved layout consistency * refactor: remove monospace font family from chat bottom bar for improved text rendering * refactor: reduce notification animation duration and change dismiss direction for improved user experience * refactor: replace VideoBar with StreamInfoBar in video components for improved layout and consistency * refactor: adjust font sizes and spacing in StreamInfoBar for improved layout consistency * refactor: standardize font sizes and update gradient stops in video components for improved visual consistency * refactor: update tooltip padding and background color in theme for improved visual consistency * refactor: enhance tooltip behavior and standardize font sizes in StreamInfoBar and related components for improved visual consistency * refactor: add showThumbnail option to LargeStreamCardSkeletonLoader for improved layout flexibility * refactor: add StreamInfoBarSkeletonLoader for improved layout consistency in stream components * refactor: add shadow effects to icons and text in StreamInfoBar and VideoOverlay for improved visual depth * refactor: update defaultOverlayOpacity to 0.0 for improved overlay behavior * fix: correct stream info text color on overlay * refactor: implement a new color picker dialog with random color generation for enhanced user experience * refactor: adjust gradient colors and stops in VideoOverlay for improved visual consistency * refactor: refine gradient colors and stops in VideoOverlay for enhanced visual clarity * refactor: add isCompact parameter to StreamInfoBar for responsive text sizing * fix: update tooltip background color to use surface color for better contrast * feat: add shared chat mode support in chat store and update StreamInfoBar for visual indication * refactor: disable debug print for IRC messages to reduce log clutter * feat: implement chat color selection feature with user color management in chat settings * refactor: replace color adjustment logic with new contrast-aware utility for chat names * refactor: simplify chat details UI by replacing list tiles with action cards and removing unused clear dialog * refactor: enhance chat details UI by improving time formatting and adding label styling to action cards * refactor: update chat color terminology to 'Username color' for clarity in UI * refactor: enhance chat details UI by introducing active chat modes display and improving layout structure * refactor: improve chat details layout by replacing ListTile with Padding and adding a Divider for better visual separation * refactor: remove chat notifications positioning setting and adjust notification alignment in video chat * refactor: streamline StreamInfoBar component by consolidating offline handling and improving layout for better readability * refactor: enhance StreamInfoBar by adding tooltip for streamer name and improving text overflow handling * refactor: adjust tooltip display duration in StreamInfoBar and update theme configuration * refactor: adjust margin and padding in StreamCard for improved layout consistency * chore: update dependencies in pubspec.yaml and pubspec.lock for Firebase and other packages * template * chore: update iOS platform version to 15.0 and Firebase dependencies to 12.2.0 * refactor: simplify StreamInfoBar layout by reducing nested widgets and improving text color handling * format * feat: enhance Channel model and StreamInfoBar to support offline channel details * feat: add offline indicator and improve StreamInfoBar layout for better offline channel display * fix: adjust icon color opacity in StreamInfoBar for improved visibility * feat: enhance dismissal animation in FrostyPhotoViewDialog to track swipe direction * docs: update copilot instructions for clarity and additional error handling details * refactor: optimize latency tooltip layout and improve offline indicator styling in VideoOverlay * feat: implement shared chat asset fetching and participant management in ChatAssetsStore * refactor: replace Observer with Builder for improved widget rebuilding in VideoOverlay * refactor: update StreamInfoBar border styling and adjust layout in VideoOverlay for improved visual consistency * feat: add showTextShadows property to StreamInfoBar for customizable text shadow display * refactor: adjust layout in VideoOverlay by adding padding to improve spacing and visual consistency * refactor: enhance VideoOverlay gradients and layout for improved visual hierarchy and user experience * refactor: reduce vertical padding in VideoOverlay for improved layout consistency * refactor: remove overlay opacity settings from VideoSettings and SettingsStore for simplification * refactor: improve text overlay transition in VideoChat for better user experience * Revert "refactor: improve text overlay transition in VideoChat for better user experience" This reverts commitfd16ff5ec3. * refactor: wrap videoOverlay in ColoredBox for improved layout handling * refactor: optimize animation duration and transition curves in VideoChat for smoother interactions * refactor: dispose searchScrollController in HomeStoreBase to prevent memory leaks * refactor: simplify gradient definitions in VideoOverlay for improved readability and maintainability * refactor: remove 'Offline' text overlay from VideoOverlay for cleaner UI * refactor: add refresh functionality for assets in ChatDetails with loading indicators * refactor: update visibility conditions for uptime, viewer count, and category display in StreamInfoBar * refactor: replace AlertDialog with FrostyDialog for consistent dialog styling across the application * refactor: enhance layout handling in FrostyPageView to prevent overflow during height transitions * refactor: replace ColorPickerDialog with AccentColorPickerDialog for improved color selection experience * refactor: replace theme and accent color settings with dedicated components for improved modularity and readability * refactor: replace ElevatedButton with TextButton for consistent button styling across dialogs * refactor: integrate MobX Observer for reactive state management in settings widgets * refactor: update theme and dialog components for improved styling and consistency * refactor: enhance dialog styling with rounded corners and border in theme configuration * refactor: improve dialog messages for unauthorized access handling * refactor: update bodyLarge text style in theme configuration to include font weight * refactor: standardize Divider usage and adjust theme styles for improved consistency * refactor: adjust Divider properties and theme styles for enhanced visual consistency * chore: remove GEMINI.md file as it is no longer needed for project documentation * refactor: enhance token validation and reconnect logic in AuthBase for improved error handling * format * refactor: integrate landscape cutout settings into VideoChat layout for improved responsiveness * refactor: implement app lifecycle handling in VideoChat and enhance stream info update logic in VideoStore * refactor: update landscape cutout conditionals in VideoChat layout for improved responsiveness * update AGENTS.md * add scrollbar to search * fix scrolling exception * update CLAUDE.md * 5 auth reconnect attemps then log out * increase reply max lines to 2 * simplify and improve reply formatting * add prime icon * better draggable divider * support draggable divider in fullscreen overlay chat * add text shadow in fullscreen chat overlay mode * format * add landscape checks * video store optimization attempts * Dispose WebViewController and remove JavaScript channels properly * Track and cleanup all MutationObservers to prevent accumulation * Throttle latency updates to once per 5 seconds (reduces CPU load) * Add timeout protection and null checks for robustness * Prevent duplicate event listeners on page reloads * double tap divider to reset * Revert "video store optimization attempts" This reverts commitb1df7b0de4. * optimize video store attempt 2 * Revert "double tap divider to reset" This reverts commitaeb26a4335. * optimize latency listener to open the menu and check every 20 secconds * more optimizations * hard refresh * abbreviate latency * fix lint * improve hide default overlay * fix chat no longer reconnecting with chat delay * refine chat alerts * fix historical message missing badges and emotes on initial connection * parallelize * revert * add reconnect button inline action * refactor: replace Scrollbar with FrostyScrollbar across chat and settings screens for consistent styling * tweak chat details menu * add scrollbar to release notes, renames for consistency * fix duplicate message case * fix chat delay alerts in auto sync mode * add regex for numeric strings and enhance display name formatting logic * exterminate borders * refactor: simplify active chat modes display in chat details screen * feat: enhance chat input button with delay notification and disable state * fix: wrap chat in SafeArea to prevent UI overflow issues * refactor: remove divider line from video chat and update draggable divider visibility logic * persist overlay in pip mode * fix: update chat delay handling and notifications in chat bottom bar * fix: update chat overlay visibility logic to respect fullscreen settings * tap to show deleted message * fix: prevent accidental chat scrolling when accessing system UI in landscape mode * show latency in portrait * fix: enhance chat delay notifications with dynamic delay time * docs: update CLAUDE.md for improved environment variable instructions and architecture overview * fix: improve chat delay countdown management and message handling * add preliminary release notes * remove agents.md * update lockfile * add initial multi chat feature * add option to persist multi chat tabs * add remove confirmation dialog * improve add chat design * lazy-load tabs * create global assets store to dedupe global asset fetching * improve tab design/styles * update notes * use frosty dialog * update claude md * tweak * add ability to reorder tabs * resolve conflict * merge cleanup * format * update release notes * add chat delay sync completion tracking to prevent countdown re-appearance * add tests * fix * update release notes * bump
This commit is contained in:
parent
b3af20d89a
commit
8f4ebde155
155 changed files with 16703 additions and 6729 deletions
102
.github/copilot-instructions.md
vendored
102
.github/copilot-instructions.md
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -32,6 +32,7 @@ migrate_working_dir/
|
|||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/coverage/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
|
@ -65,3 +66,4 @@ random/
|
|||
|
||||
# ai
|
||||
.claude
|
||||
.cursor
|
||||
118
CLAUDE.md
118
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.
|
||||
- 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.
|
||||
|
|
|
|||
35
GEMINI.md
35
GEMINI.md
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ pluginManagement {
|
|||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "8.2.1" apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.0.21" apply false
|
||||
id "com.android.application" version "8.6.0" apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
|
||||
id "com.google.gms.google-services" version "4.3.15" apply false
|
||||
id "com.google.firebase.firebase-perf" version "1.4.2" apply false
|
||||
id "com.google.firebase.crashlytics" version "2.9.9" apply false
|
||||
|
|
|
|||
|
|
@ -1,77 +1,30 @@
|
|||
## v4.9.6
|
||||
## v5.0.0
|
||||
|
||||
June 14, 2025
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- [Android] Fix missing stream quality options
|
||||
|
||||
---
|
||||
|
||||
## v4.9.5
|
||||
|
||||
June 13, 2025
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix stream latency no longer updating
|
||||
|
||||
---
|
||||
|
||||
## v4.9.4
|
||||
|
||||
June 6, 2025
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix video stats blocking when using custom overlay
|
||||
|
||||
---
|
||||
|
||||
## v4.9.3
|
||||
|
||||
May 26, 2025
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix missing 7TV emotes on some channels
|
||||
- Fix sleep timer menu border styling
|
||||
|
||||
---
|
||||
|
||||
## v4.9.2
|
||||
|
||||
April 7, 2025
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix large cache size (by @Artiu on GitHub)
|
||||
|
||||
---
|
||||
|
||||
## v4.9.1
|
||||
|
||||
April 4, 2025
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- [Android] Fix overlay latency and stream qualities, persist last chosen stream quality (by @Artiu on GitHub)
|
||||
|
||||
---
|
||||
|
||||
## v4.9.0
|
||||
|
||||
December 30, 2024
|
||||
January 1, 2026
|
||||
|
||||
### New Features
|
||||
|
||||
- [Android] Added new "Use enhanced rendering" option in video settings
|
||||
- Open multiple chat tabs for different channels with drag-to-reorder. Tabs are saved between sessions by default (configurable in settings).
|
||||
- Resize the chat and video areas in landscape mode by dragging the divider.
|
||||
- A new section in the Following tab shows which of your followed channels are currently offline.
|
||||
- Change your chat username color from the chat details menu.
|
||||
- A new randomize button for the accent color theme in settings.
|
||||
- View emotes and stream thumbnails in full resolution with swipe-to-dismiss gestures.
|
||||
- [Android] Open Twitch channel links directly in Frosty instead of the browser or Twitch app (by @micahmo on GitHub).
|
||||
|
||||
### Improvements
|
||||
|
||||
- Swipe down to enter Picture-in-Picture mode, and press the PiP button again to exit.
|
||||
- Redesigned the video overlay and added stream details to the app bar in chat-only mode.
|
||||
- Networking and data handling have been completely rewritten to be faster and more reliable.
|
||||
- Improved formatting for chat replies, quick copy-to-input for messages, and a clearer countdown for chat delays.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed incorrect badge order and spacing for FFZ bots
|
||||
- Fixed missing emotes on channels that don't have 7TV enabled
|
||||
- Fixed chat text input not unfocusing when tapping outside of it (by @zeykafx on GitHub).
|
||||
- Fixed missing badges and emotes in historical messages upon initial connection.
|
||||
- Fixed accidental chat scrolling when accessing system UI gestures in landscape mode.
|
||||
- Fixed chat reconnection issues when a chat delay was active.
|
||||
- Fixed duplicate message cases and various UI stability issues.
|
||||
|
||||
#### See all release notes at https://github.com/tommyxchow/frosty/releases
|
||||
|
||||
---
|
||||
#### See all release notes at [https://github.com/tommyxchow/frosty/releases](https://github.com/tommyxchow/frosty/releases)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,6 @@
|
|||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>12.0</string>
|
||||
<string>13.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
249
ios/Podfile.lock
249
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
|
||||
|
|
|
|||
248
lib/apis/base_api_client.dart
Normal file
248
lib/apis/base_api_client.dart
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import 'package:dio/dio.dart';
|
||||
|
||||
/// Common type aliases to reduce repetition
|
||||
typedef JsonMap = Map<String, dynamic>;
|
||||
typedef JsonList = List<dynamic>;
|
||||
typedef QueryParams = Map<String, dynamic>;
|
||||
typedef ApiHeaders = Map<String, String>;
|
||||
|
||||
/// 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<T> get<T>(
|
||||
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<T> post<T>(
|
||||
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<T> put<T>(
|
||||
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<T> delete<T>(
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<List<Emote>> getEmotesGlobal() async {
|
||||
final url = Uri.parse('https://api.betterttv.net/3/cached/emotes/global');
|
||||
final data = await get<JsonList>('/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');
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a map of a channel's BTTV emotes to their URL.
|
||||
Future<List<Emote>> getEmotesChannel({required String id}) async {
|
||||
final url =
|
||||
Uri.parse('https://api.betterttv.net/3/cached/users/twitch/$id');
|
||||
final data = await get<JsonMap>('/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 = <Emote>[];
|
||||
emoteToUrl.addAll(
|
||||
result.channelEmotes
|
||||
.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvChannel)),
|
||||
result.channelEmotes.map(
|
||||
(emote) => Emote.fromBTTV(emote, EmoteType.bttvChannel),
|
||||
),
|
||||
);
|
||||
emoteToUrl.addAll(
|
||||
result.sharedEmotes
|
||||
.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvShared)),
|
||||
result.sharedEmotes.map(
|
||||
(emote) => Emote.fromBTTV(emote, EmoteType.bttvShared),
|
||||
),
|
||||
);
|
||||
|
||||
return emoteToUrl;
|
||||
} else {
|
||||
return Future.error('Failed to get BTTV channel emotes');
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a map of username to their BTTV badge.
|
||||
Future<Map<String, ChatBadge>> getBadges() async {
|
||||
final url = Uri.parse('https://api.betterttv.net/3/cached/badges');
|
||||
final data = await get<JsonList>('/badges');
|
||||
|
||||
final response = await _client.get(url);
|
||||
if (response.statusCode == 200) {
|
||||
final badges = jsonDecode(response.body) as List;
|
||||
|
||||
final badgeObjects =
|
||||
badges.map((badge) => BadgeInfoBTTV.fromJson(badge)).toList();
|
||||
final badgeObjects = data
|
||||
.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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
67
lib/apis/dio_client.dart
Normal file
67
lib/apis/dio_client.dart
Normal file
|
|
@ -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<Interceptor>? 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,49 +1,36 @@
|
|||
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<List<Emote>> getEmotesGlobal() async {
|
||||
final url = Uri.parse('https://api.frankerfacez.com/v1/set/global');
|
||||
final data = await get<JsonMap>('/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 = <EmoteFFZ>[];
|
||||
for (final setId in defaultSets) {
|
||||
final emoticons =
|
||||
decoded['sets'][setId.toString()]['emoticons'] as List;
|
||||
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();
|
||||
} else {
|
||||
return Future.error('Failed to get FFZ global emotes');
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a channel's FFZ room info including custom badges and emote used.
|
||||
Future<(RoomFFZ, List<Emote>)> getRoomInfo({required String id}) async {
|
||||
final url = Uri.parse('https://api.frankerfacez.com/v1/room/id/$id');
|
||||
final data = await get<JsonMap>('/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 roomInfo = RoomFFZ.fromJson(data['room']);
|
||||
final emoticons =
|
||||
decoded['sets'][roomInfo.set.toString()]['emoticons'] as List;
|
||||
data['sets'][roomInfo.set.toString()]['emoticons'] as JsonList;
|
||||
|
||||
final emotes = emoticons.map((emote) => EmoteFFZ.fromJson(emote));
|
||||
|
||||
|
|
@ -51,27 +38,22 @@ class FFZApi {
|
|||
roomInfo,
|
||||
emotes
|
||||
.map((emote) => Emote.fromFFZ(emote, EmoteType.ffzChannel))
|
||||
.toList()
|
||||
.toList(),
|
||||
);
|
||||
} else {
|
||||
return Future.error('Failed to get FFZ room info');
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a map of badges user IDs to a list of their FFZ badges.
|
||||
Future<Map<String, List<ChatBadge>>> getBadges() async {
|
||||
final url = Uri.parse('https://api.frankerfacez.com/v1/badges/ids');
|
||||
final data = await get<JsonMap>('/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 = <String, List<ChatBadge>>{};
|
||||
for (final badge in badgeObjects.reversed) {
|
||||
for (final userId in decoded['users'][badge.id.toString()]) {
|
||||
for (final userId in data['users'][badge.id.toString()]) {
|
||||
final entry = result[userId.toString()];
|
||||
final normalBadge = ChatBadge.fromFFZ(badge);
|
||||
if (entry == null) {
|
||||
|
|
@ -83,8 +65,5 @@ class FFZApi {
|
|||
}
|
||||
|
||||
return result;
|
||||
} else {
|
||||
return Future.error('Failed to get FFZ badges');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<List<Emote>> getEmotesGlobal() async {
|
||||
final url = Uri.parse('https://7tv.io/v3/emote-sets/global');
|
||||
final data = await get<JsonMap>('/emote-sets/global');
|
||||
|
||||
final response = await _client.get(url);
|
||||
if (response.statusCode == 200) {
|
||||
final decoded = jsonDecode(response.body)['emotes'] as List;
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a tuple containing the emote set ID and a map of a channel's 7TV
|
||||
/// emotes to their URL.
|
||||
Future<(String, List<Emote>)> getEmotesChannel({required String id}) async {
|
||||
final url = Uri.parse('https://7tv.io/v3/users/twitch/$id');
|
||||
final data = await get<JsonMap>('/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()
|
||||
.toList(),
|
||||
);
|
||||
} else {
|
||||
return Future.error('Failed to get 7TV channel emotes');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,55 +1,46 @@
|
|||
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<List<Emote>> getEmotesGlobal({
|
||||
required Map<String, String> headers,
|
||||
}) async {
|
||||
final url = Uri.parse('https://api.twitch.tv/helix/chat/emotes/global');
|
||||
final response = await _client.get(url, headers: headers);
|
||||
Future<List<Emote>> getEmotesGlobal() async {
|
||||
final data = await get<JsonMap>('/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');
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a list of a channel's Twitch emotes given their [id].
|
||||
Future<List<Emote>> getEmotesChannel({
|
||||
required String id,
|
||||
required Map<String, String> headers,
|
||||
}) async {
|
||||
final url =
|
||||
Uri.parse('https://api.twitch.tv/helix/chat/emotes?broadcaster_id=$id');
|
||||
Future<List<Emote>> getEmotesChannel({required String id}) async {
|
||||
final data = await get<JsonMap>(
|
||||
'/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) {
|
||||
|
|
@ -63,25 +54,17 @@ class TwitchApi {
|
|||
return Emote.fromTwitch(emote, EmoteType.twitchChannel);
|
||||
}
|
||||
}).toList();
|
||||
} else {
|
||||
return Future.error('Failed to get Twitch channel emotes');
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a list of Twitch emotes under the provided [setId].
|
||||
Future<List<Emote>> getEmotesSets({
|
||||
required String setId,
|
||||
required Map<String, String> headers,
|
||||
}) async {
|
||||
final url = Uri.parse(
|
||||
'https://api.twitch.tv/helix/chat/emotes/set?emote_set_id=$setId',
|
||||
Future<List<Emote>> getEmotesSets({required String setId}) async {
|
||||
final data = await get<JsonMap>(
|
||||
'/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) {
|
||||
|
|
@ -94,25 +77,18 @@ class TwitchApi {
|
|||
return Emote.fromTwitch(emote, EmoteType.twitchUnlocked);
|
||||
}
|
||||
}).toList();
|
||||
} else {
|
||||
return Future.error('Failed to get Twitch emotes set');
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a map of global Twitch badges to their [Emote] object.
|
||||
Future<Map<String, ChatBadge>> getBadgesGlobal({
|
||||
required Map<String, String> headers,
|
||||
}) async {
|
||||
final url = Uri.parse('https://api.twitch.tv/helix/chat/badges/global');
|
||||
Future<Map<String, ChatBadge>> getBadgesGlobal() async {
|
||||
final data = await get<JsonMap>('/chat/badges/global');
|
||||
|
||||
final response = await _client.get(url, headers: headers);
|
||||
if (response.statusCode == 200) {
|
||||
final result = <String, ChatBadge>{};
|
||||
final decoded = jsonDecode(response.body)['data'] as List;
|
||||
final decoded = data['data'] as JsonList;
|
||||
|
||||
for (final badge in decoded) {
|
||||
final id = badge['set_id'] as String;
|
||||
final versions = badge['versions'] as List;
|
||||
final versions = badge['versions'] as JsonList;
|
||||
|
||||
for (final version in versions) {
|
||||
final badgeInfo = BadgeInfoTwitch.fromJson(version);
|
||||
|
|
@ -121,27 +97,21 @@ class TwitchApi {
|
|||
}
|
||||
|
||||
return result;
|
||||
} else {
|
||||
return Future.error('Failed to get Twitch global badges');
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a map of a channel's Twitch badges to their [Emote] object.
|
||||
Future<Map<String, ChatBadge>> getBadgesChannel({
|
||||
required String id,
|
||||
required Map<String, String> headers,
|
||||
}) async {
|
||||
final url =
|
||||
Uri.parse('https://api.twitch.tv/helix/chat/badges?broadcaster_id=$id');
|
||||
Future<Map<String, ChatBadge>> getBadgesChannel({required String id}) async {
|
||||
final data = await get<JsonMap>(
|
||||
'/chat/badges',
|
||||
queryParameters: {'broadcaster_id': id},
|
||||
);
|
||||
|
||||
final response = await _client.get(url, headers: headers);
|
||||
if (response.statusCode == 200) {
|
||||
final result = <String, ChatBadge>{};
|
||||
final decoded = jsonDecode(response.body)['data'] as List;
|
||||
final decoded = data['data'] as JsonList;
|
||||
|
||||
for (final badge in decoded) {
|
||||
final id = badge['set_id'] as String;
|
||||
final versions = badge['versions'] as List;
|
||||
final versions = badge['versions'] as JsonList;
|
||||
|
||||
for (final version in versions) {
|
||||
final badgeInfo = BadgeInfoTwitch.fromJson(version);
|
||||
|
|
@ -150,31 +120,21 @@ class TwitchApi {
|
|||
}
|
||||
|
||||
return result;
|
||||
} else {
|
||||
return Future.error('Failed to get Twitch channel badges');
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the user's info given their token through [headers].
|
||||
Future<UserTwitch> getUserInfo({required Map<String, String> headers}) async {
|
||||
final url = Uri.parse('https://api.twitch.tv/helix/users');
|
||||
|
||||
final response = await _client.get(url, headers: headers);
|
||||
if (response.statusCode == 200) {
|
||||
final userData = jsonDecode(response.body)['data'] as List;
|
||||
/// Returns the user's info given their token.
|
||||
Future<UserTwitch> getUserInfo() async {
|
||||
final data = await get<JsonMap>('/users');
|
||||
|
||||
final userData = data['data'] as JsonList;
|
||||
return UserTwitch.fromJson(userData.first);
|
||||
} else {
|
||||
return Future.error('Failed to get Twitch user info');
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a token for an anonymous user.
|
||||
Future<String> getDefaultToken() async {
|
||||
final url = Uri(
|
||||
scheme: 'https',
|
||||
host: 'id.twitch.tv',
|
||||
path: '/oauth2/token',
|
||||
// Use custom base URL for OAuth
|
||||
final data = await post<JsonMap>(
|
||||
'$_oauthBaseUrl/token',
|
||||
queryParameters: {
|
||||
'client_id': clientId,
|
||||
'client_secret': secret,
|
||||
|
|
@ -182,306 +142,217 @@ 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<bool> 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<JsonMap>(
|
||||
'$_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<StreamsTwitch> getTopStreams({
|
||||
required Map<String, String> 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<StreamsTwitch> getTopStreams({String? cursor}) async {
|
||||
final data = await get<JsonMap>(
|
||||
'/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<StreamsTwitch> getFollowedStreams({
|
||||
required String id,
|
||||
required Map<String, String> 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<JsonMap>(
|
||||
'/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<FollowedChannels> getFollowedChannels({
|
||||
required String userId,
|
||||
String? cursor,
|
||||
}) async {
|
||||
final queryParams = {'user_id': userId, 'first': '20'};
|
||||
if (cursor != null) queryParams['after'] = cursor;
|
||||
|
||||
final data = await get<JsonMap>(
|
||||
'/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<StreamsTwitch> getStreamsUnderCategory({
|
||||
required String gameId,
|
||||
required Map<String, String> 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<JsonMap>('/streams', queryParameters: queryParams);
|
||||
|
||||
return StreamsTwitch.fromJson(data);
|
||||
}
|
||||
|
||||
/// Returns a [StreamTwitch] object containing the stream info associated with the given [userLogin].
|
||||
Future<StreamTwitch> getStream({
|
||||
required String userLogin,
|
||||
required Map<String, String> headers,
|
||||
}) async {
|
||||
final uri =
|
||||
Uri.parse('https://api.twitch.tv/helix/streams?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');
|
||||
}
|
||||
} else {
|
||||
return Future.error('Failed to get stream info');
|
||||
}
|
||||
}
|
||||
|
||||
Future<StreamsTwitch> getStreamsByIds({
|
||||
required List<String> userIds,
|
||||
required Map<String, String> headers,
|
||||
}) async {
|
||||
final uri = Uri.parse(
|
||||
'https://api.twitch.tv/helix/streams?${userIds.map((e) => 'user_id=$e').join('&')}&first=100',
|
||||
Future<StreamTwitch> getStream({required String userLogin}) async {
|
||||
final data = await get<JsonMap>(
|
||||
'/streams',
|
||||
queryParameters: {'user_login': userLogin},
|
||||
);
|
||||
|
||||
final response = await _client.get(uri, headers: headers);
|
||||
if (response.statusCode == 200) {
|
||||
final decoded = jsonDecode(response.body);
|
||||
return StreamsTwitch.fromJson(decoded);
|
||||
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<StreamsTwitch> getStreamsByIds({required List<String> 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 data = await get<JsonMap>(url);
|
||||
|
||||
return StreamsTwitch.fromJson(data);
|
||||
}
|
||||
|
||||
/// Returns a [UserTwitch] object containing the user info associated with the given [userLogin].
|
||||
Future<UserTwitch> getUser({
|
||||
String? userLogin,
|
||||
String? id,
|
||||
required Map<String, String> 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<UserTwitch> getUser({String? userLogin, String? id}) async {
|
||||
final queryParams = <String, String>{};
|
||||
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);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final userData = decoded['data'] as List;
|
||||
final data = await get<JsonMap>('/users', queryParameters: queryParams);
|
||||
|
||||
final userData = data['data'] as JsonList;
|
||||
if (userData.isNotEmpty) {
|
||||
return UserTwitch.fromJson(userData.first);
|
||||
} else {
|
||||
return Future.error('User does not exist');
|
||||
}
|
||||
} 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<Channel> getChannel({
|
||||
required String userId,
|
||||
required Map<String, String> headers,
|
||||
}) async {
|
||||
final url = Uri.parse(
|
||||
'https://api.twitch.tv/helix/channels?broadcaster_id=$userId',
|
||||
Future<Channel> getChannel({required String userId}) async {
|
||||
final data = await get<JsonMap>(
|
||||
'/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;
|
||||
|
||||
final channelData = data['data'] as JsonList;
|
||||
if (channelData.isNotEmpty) {
|
||||
return Channel.fromJson(channelData.first);
|
||||
} else {
|
||||
return Future.error('Channel does not exist');
|
||||
}
|
||||
} 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<List<ChannelQuery>> searchChannels({
|
||||
required String query,
|
||||
required Map<String, String> headers,
|
||||
}) async {
|
||||
final url = Uri.parse(
|
||||
'https://api.twitch.tv/helix/search/channels?first=8&query=$query',
|
||||
Future<List<ChannelQuery>> searchChannels({required String query}) async {
|
||||
final data = await get<JsonMap>(
|
||||
'/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;
|
||||
|
||||
final channelData = data['data'] as JsonList;
|
||||
return channelData.map((e) => ChannelQuery.fromJson(e)).toList();
|
||||
} else {
|
||||
return Future.error('Failed to get channels');
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a [CategoriesTwitch] object containing the next top 20 categories/games and a cursor for further requests.
|
||||
Future<CategoriesTwitch> getTopCategories({
|
||||
required Map<String, String> 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<CategoriesTwitch> getTopCategories({String? cursor}) async {
|
||||
final data = await get<JsonMap>(
|
||||
'/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<CategoriesTwitch> getCategory({
|
||||
required Map<String, String> headers,
|
||||
required String gameId,
|
||||
}) async {
|
||||
final url = Uri.parse('https://api.twitch.tv/helix/games?id=$gameId');
|
||||
Future<CategoriesTwitch> getCategory({required String gameId}) async {
|
||||
final data = await get<JsonMap>('/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<CategoriesTwitch> searchCategories({
|
||||
required Map<String, String> 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<JsonMap>(
|
||||
'/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<int> getSubscriberCount({
|
||||
required String userId,
|
||||
required Map<String, String> headers,
|
||||
}) async {
|
||||
final uri = Uri.parse(
|
||||
'https://api.twitch.tv/helix/subscriptions?broadcaster_id=$userId',
|
||||
Future<int> getSubscriberCount({required String userId}) async {
|
||||
final data = await get<JsonMap>(
|
||||
'/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<List<UserBlockedTwitch>> getUserBlockedList({
|
||||
required String id,
|
||||
required Map<String, String> 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<JsonMap>(
|
||||
'/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();
|
||||
final result = blockedList
|
||||
.map((e) => UserBlockedTwitch.fromJson(e))
|
||||
.toList();
|
||||
|
||||
if (cursor != null) {
|
||||
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.
|
||||
//
|
||||
|
|
@ -489,11 +360,7 @@ class TwitchApi {
|
|||
// 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,
|
||||
),
|
||||
await getUserBlockedList(id: id, cursor: paginationCursor),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -502,83 +369,100 @@ class TwitchApi {
|
|||
debugPrint('User does not have anyone blocked');
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
return Future.error('User does not exist');
|
||||
}
|
||||
}
|
||||
|
||||
// Blocks the user with the given ID and returns true on success or false on failure.
|
||||
Future<bool> blockUser({
|
||||
required String userId,
|
||||
required Map<String, String> headers,
|
||||
}) async {
|
||||
final url = Uri.parse(
|
||||
'https://api.twitch.tv/helix/users/blocks?target_user_id=$userId',
|
||||
Future<bool> blockUser({required String userId}) async {
|
||||
try {
|
||||
await put<dynamic>(
|
||||
'/users/blocks',
|
||||
queryParameters: {'target_user_id': userId},
|
||||
);
|
||||
|
||||
final response = await _client.put(url, headers: headers);
|
||||
if (response.statusCode == 204) {
|
||||
return true;
|
||||
} else {
|
||||
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<bool> unblockUser({
|
||||
required String userId,
|
||||
required Map<String, String> headers,
|
||||
}) async {
|
||||
final url = Uri.parse(
|
||||
'https://api.twitch.tv/helix/users/blocks?target_user_id=$userId',
|
||||
Future<bool> unblockUser({required String userId}) async {
|
||||
try {
|
||||
await delete<dynamic>(
|
||||
'/users/blocks',
|
||||
queryParameters: {'target_user_id': userId},
|
||||
);
|
||||
|
||||
final response = await _client.delete(url, headers: headers);
|
||||
if (response.statusCode == 204) {
|
||||
return true;
|
||||
} else {
|
||||
return true; // If no exception, operation succeeded
|
||||
} on ApiException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<SharedChatSession?> getSharedChatSession({
|
||||
required String broadcasterId,
|
||||
required Map<String, String> headers,
|
||||
}) async {
|
||||
final url = Uri.parse(
|
||||
'https://api.twitch.tv/helix/shared_chat/session?broadcaster_id=$broadcasterId',
|
||||
final data = await get<JsonMap>(
|
||||
'/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;
|
||||
}
|
||||
|
||||
return SharedChatSession.fromJson(sessionData.first);
|
||||
} else {
|
||||
return Future.error('Failed to get shared chat session info');
|
||||
}
|
||||
}
|
||||
|
||||
// Unblocks the user with the given ID and returns true on success or false on failure.
|
||||
Future<List<dynamic>> getRecentMessages({
|
||||
required String userLogin,
|
||||
}) async {
|
||||
final url = Uri.parse(
|
||||
'https://recent-messages.robotty.de/api/v2/recent-messages/$userLogin',
|
||||
/// 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<String> getUserChatColor({required String userId}) async {
|
||||
try {
|
||||
final data = await get<JsonMap>(
|
||||
'/chat/color',
|
||||
queryParameters: {'user_id': userId},
|
||||
);
|
||||
|
||||
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');
|
||||
final users = data['data'] as JsonList;
|
||||
if (users.isNotEmpty) {
|
||||
final user = users.first as JsonMap;
|
||||
return user['color'] as String? ?? '';
|
||||
}
|
||||
|
||||
return '';
|
||||
} on ApiException {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<bool> updateUserChatColor({
|
||||
required String userId,
|
||||
required String color,
|
||||
}) async {
|
||||
try {
|
||||
await put<dynamic>(
|
||||
'/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<JsonList> getRecentMessages({required String userLogin}) async {
|
||||
// Use custom base URL for third-party service
|
||||
final data = await get<JsonMap>(
|
||||
'$_recentMessagesUrl/recent-messages/$userLogin',
|
||||
);
|
||||
|
||||
return data['messages'] as JsonList;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
31
lib/apis/twitch_auth_interceptor.dart
Normal file
31
lib/apis/twitch_auth_interceptor.dart
Normal file
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
91
lib/apis/unauthorized_interceptor.dart
Normal file
91
lib/apis/unauthorized_interceptor.dart
Normal file
|
|
@ -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<AuthStore>.value(
|
||||
value: _authStore,
|
||||
child: const LoginWebView(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text('Log in'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
).then((_) {
|
||||
// Reset flag when dialog is dismissed
|
||||
_isDialogShowing = false;
|
||||
});
|
||||
}
|
||||
|
||||
static bool _isDialogShowing = false;
|
||||
}
|
||||
|
|
@ -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'(?<![A-Za-z0-9_.-])' // left boundary
|
||||
|
|
@ -42,3 +47,41 @@ const defaultBadgeSize = 18.0;
|
|||
|
||||
/// The default emote width and height when none are provided.
|
||||
const defaultEmoteSize = 28.0;
|
||||
|
||||
/// Available named chat colors for Twitch users.
|
||||
const chatColorNames = [
|
||||
'blue',
|
||||
'blue_violet',
|
||||
'cadet_blue',
|
||||
'chocolate',
|
||||
'coral',
|
||||
'dodger_blue',
|
||||
'firebrick',
|
||||
'golden_rod',
|
||||
'green',
|
||||
'hot_pink',
|
||||
'orange_red',
|
||||
'red',
|
||||
'sea_green',
|
||||
'spring_green',
|
||||
'yellow_green',
|
||||
];
|
||||
|
||||
/// Color values for the named chat colors.
|
||||
const Map<String, Color> 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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<AuthStore>(create: (_) => authStore),
|
||||
Provider<SettingsStore>(create: (_) => settingsStore),
|
||||
Provider<TwitchApi>(create: (_) => twitchApiService),
|
||||
Provider<BTTVApi>(create: (_) => bttvApiService),
|
||||
Provider<FFZApi>(create: (_) => ffzApiService),
|
||||
Provider<SevenTVApi>(create: (_) => sevenTVApiService),
|
||||
Provider<AuthStore>.value(value: authStore),
|
||||
Provider<SettingsStore>.value(value: settingsStore),
|
||||
Provider<TwitchApi>.value(value: twitchApiService),
|
||||
Provider<BTTVApi>.value(value: bttvApiService),
|
||||
Provider<FFZApi>.value(value: ffzApiService),
|
||||
Provider<SevenTVApi>.value(value: sevenTVApiService),
|
||||
Provider<GlobalAssetsStore>.value(value: globalAssetsStore),
|
||||
],
|
||||
child: MyApp(firstRun: firstRun),
|
||||
),
|
||||
|
|
@ -105,10 +124,7 @@ final navigatorKey = GlobalKey<NavigatorState>();
|
|||
class MyApp extends StatefulWidget {
|
||||
final bool firstRun;
|
||||
|
||||
const MyApp({
|
||||
super.key,
|
||||
this.firstRun = false,
|
||||
});
|
||||
const MyApp({super.key, this.firstRun = false});
|
||||
|
||||
@override
|
||||
State<MyApp> createState() => _MyAppState();
|
||||
|
|
@ -137,8 +153,9 @@ class _MyAppState extends State<MyApp> {
|
|||
return Observer(
|
||||
builder: (context) {
|
||||
final settingsStore = context.read<SettingsStore>();
|
||||
final themes =
|
||||
FrostyThemes(colorSchemeSeed: Color(settingsStore.accentColor));
|
||||
final themes = FrostyThemes(
|
||||
colorSchemeSeed: Color(settingsStore.accentColor),
|
||||
);
|
||||
|
||||
return Provider<FrostyThemes>(
|
||||
create: (_) => themes,
|
||||
|
|
@ -198,12 +215,8 @@ class _MyAppState extends State<MyApp> {
|
|||
|
||||
try {
|
||||
final twitchApi = context.read<TwitchApi>();
|
||||
final authStore = context.read<AuthStore>();
|
||||
|
||||
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<MyApp> {
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String, dynamic> 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<String, dynamic> json) =>
|
||||
_$BadgeUrlsFFZFromJson(json);
|
||||
|
|
@ -71,11 +62,7 @@ class BadgeInfo7TV {
|
|||
final List<List<String>> urls;
|
||||
final List<String> users;
|
||||
|
||||
BadgeInfo7TV(
|
||||
this.tooltip,
|
||||
this.urls,
|
||||
this.users,
|
||||
);
|
||||
BadgeInfo7TV(this.tooltip, this.urls, this.users);
|
||||
|
||||
factory BadgeInfo7TV.fromJson(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) =>
|
||||
_$BadgeDetailsBTTVFromJson(json);
|
||||
|
|
|
|||
|
|
@ -21,13 +21,10 @@ BadgeInfoFFZ _$BadgeInfoFFZFromJson(Map<String, dynamic> json) => BadgeInfoFFZ(
|
|||
json['title'] as String,
|
||||
json['color'] as String,
|
||||
BadgeUrlsFFZ.fromJson(json['urls'] as Map<String, dynamic>),
|
||||
);
|
||||
);
|
||||
|
||||
BadgeUrlsFFZ _$BadgeUrlsFFZFromJson(Map<String, dynamic> json) => BadgeUrlsFFZ(
|
||||
json['1'] as String,
|
||||
json['2'] as String,
|
||||
json['4'] as String,
|
||||
);
|
||||
BadgeUrlsFFZ _$BadgeUrlsFFZFromJson(Map<String, dynamic> json) =>
|
||||
BadgeUrlsFFZ(json['1'] as String, json['2'] as String, json['4'] as String);
|
||||
|
||||
BadgeInfo7TV _$BadgeInfo7TVFromJson(Map<String, dynamic> json) => BadgeInfo7TV(
|
||||
json['tooltip'] as String,
|
||||
|
|
@ -35,7 +32,7 @@ BadgeInfo7TV _$BadgeInfo7TVFromJson(Map<String, dynamic> json) => BadgeInfo7TV(
|
|||
.map((e) => (e as List<dynamic>).map((e) => e as String).toList())
|
||||
.toList(),
|
||||
(json['users'] as List<dynamic>).map((e) => e as String).toList(),
|
||||
);
|
||||
);
|
||||
|
||||
BadgeInfoBTTV _$BadgeInfoBTTVFromJson(Map<String, dynamic> json) =>
|
||||
BadgeInfoBTTV(
|
||||
|
|
@ -44,7 +41,4 @@ BadgeInfoBTTV _$BadgeInfoBTTVFromJson(Map<String, dynamic> json) =>
|
|||
);
|
||||
|
||||
BadgeDetailsBTTV _$BadgeDetailsBTTVFromJson(Map<String, dynamic> json) =>
|
||||
BadgeDetailsBTTV(
|
||||
json['description'] as String,
|
||||
json['svg'] as String,
|
||||
);
|
||||
BadgeDetailsBTTV(json['description'] as String, json['svg'] as String);
|
||||
|
|
|
|||
|
|
@ -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<String, dynamic> json) =>
|
||||
_$CategoryTwitchFromJson(json);
|
||||
|
|
@ -23,10 +19,7 @@ class CategoriesTwitch {
|
|||
final List<CategoryTwitch> data;
|
||||
final Map<String, String>? pagination;
|
||||
|
||||
const CategoriesTwitch(
|
||||
this.data,
|
||||
this.pagination,
|
||||
);
|
||||
const CategoriesTwitch(this.data, this.pagination);
|
||||
|
||||
factory CategoriesTwitch.fromJson(Map<String, dynamic> json) =>
|
||||
_$CategoriesTwitchFromJson(json);
|
||||
|
|
|
|||
|
|
@ -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<String, dynamic> json) =>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ Channel _$ChannelFromJson(Map<String, dynamic> json) => Channel(
|
|||
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<String, dynamic> json) => ChannelQuery(
|
||||
broadcasterLogin: json['broadcaster_login'] as String,
|
||||
|
|
@ -18,4 +22,4 @@ ChannelQuery _$ChannelQueryFromJson(Map<String, dynamic> json) => ChannelQuery(
|
|||
id: json['id'] as String,
|
||||
isLive: json['is_live'] as bool,
|
||||
startedAt: json['started_at'] as String,
|
||||
);
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<String, dynamic> json) =>
|
||||
_$ChatUsersFromJson(json);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ part of 'chatters.dart';
|
|||
ChatUsers _$ChatUsersFromJson(Map<String, dynamic> json) => ChatUsers(
|
||||
(json['chatter_count'] as num).toInt(),
|
||||
Chatters.fromJson(json['chatters'] as Map<String, dynamic>),
|
||||
);
|
||||
);
|
||||
|
||||
Chatters _$ChattersFromJson(Map<String, dynamic> json) => Chatters(
|
||||
(json['broadcaster'] as List<dynamic>).map((e) => e as String).toList(),
|
||||
|
|
@ -19,4 +19,4 @@ Chatters _$ChattersFromJson(Map<String, dynamic> json) => Chatters(
|
|||
(json['admins'] as List<dynamic>).map((e) => e as String).toList(),
|
||||
(json['global_mods'] as List<dynamic>).map((e) => e as String).toList(),
|
||||
(json['viewers'] as List<dynamic>).map((e) => e as String).toList(),
|
||||
);
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<String, dynamic> 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<String, dynamic> json) =>
|
||||
_$EmoteBTTVFromJson(json);
|
||||
|
|
@ -43,10 +35,7 @@ class EmoteBTTVChannel {
|
|||
final List<EmoteBTTV> channelEmotes;
|
||||
final List<EmoteBTTV> sharedEmotes;
|
||||
|
||||
const EmoteBTTVChannel(
|
||||
this.channelEmotes,
|
||||
this.sharedEmotes,
|
||||
);
|
||||
const EmoteBTTVChannel(this.channelEmotes, this.sharedEmotes);
|
||||
|
||||
factory EmoteBTTVChannel.fromJson(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) =>
|
||||
_$Emote7TVDataFromJson(json);
|
||||
|
|
@ -179,10 +144,7 @@ class Emote7TVHost {
|
|||
final String url;
|
||||
final List<Emote7TVFile> files;
|
||||
|
||||
Emote7TVHost(
|
||||
this.url,
|
||||
this.files,
|
||||
);
|
||||
Emote7TVHost(this.url, this.files);
|
||||
|
||||
factory Emote7TVHost.fromJson(Map<String, dynamic> 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<String, dynamic> json) =>
|
||||
_$Emote7TVFileFromJson(json);
|
||||
|
|
@ -254,7 +211,8 @@ class Emote {
|
|||
zeroWidth: false,
|
||||
width: emote.width,
|
||||
height: emote.height,
|
||||
url: emote.animated?.url4x ??
|
||||
url:
|
||||
emote.animated?.url4x ??
|
||||
emote.animated?.url2x ??
|
||||
emote.animated?.url1x ??
|
||||
emote.urls.url4x ??
|
||||
|
|
|
|||
|
|
@ -11,12 +11,10 @@ EmoteTwitch _$EmoteTwitchFromJson(Map<String, dynamic> json) => EmoteTwitch(
|
|||
json['name'] as String,
|
||||
json['emote_type'] as String?,
|
||||
json['owner_id'] as String?,
|
||||
);
|
||||
);
|
||||
|
||||
EmoteBTTV _$EmoteBTTVFromJson(Map<String, dynamic> json) => EmoteBTTV(
|
||||
json['id'] as String,
|
||||
json['code'] as String,
|
||||
);
|
||||
EmoteBTTV _$EmoteBTTVFromJson(Map<String, dynamic> json) =>
|
||||
EmoteBTTV(json['id'] as String, json['code'] as String);
|
||||
|
||||
EmoteBTTVChannel _$EmoteBTTVChannelFromJson(Map<String, dynamic> json) =>
|
||||
EmoteBTTVChannel(
|
||||
|
|
@ -36,18 +34,15 @@ RoomFFZ _$RoomFFZFromJson(Map<String, dynamic> json) => RoomFFZ(
|
|||
json['mod_urls'] == null
|
||||
? null
|
||||
: ImagesFFZ.fromJson(json['mod_urls'] as Map<String, dynamic>),
|
||||
);
|
||||
);
|
||||
|
||||
ImagesFFZ _$ImagesFFZFromJson(Map<String, dynamic> json) => ImagesFFZ(
|
||||
json['1'] as String,
|
||||
json['2'] as String?,
|
||||
json['4'] as String?,
|
||||
);
|
||||
ImagesFFZ _$ImagesFFZFromJson(Map<String, dynamic> json) =>
|
||||
ImagesFFZ(json['1'] as String, json['2'] as String?, json['4'] as String?);
|
||||
|
||||
OwnerFFZ _$OwnerFFZFromJson(Map<String, dynamic> json) => OwnerFFZ(
|
||||
displayName: json['display_name'] as String,
|
||||
name: json['name'] as String,
|
||||
);
|
||||
);
|
||||
|
||||
EmoteFFZ _$EmoteFFZFromJson(Map<String, dynamic> json) => EmoteFFZ(
|
||||
json['name'] as String,
|
||||
|
|
@ -58,18 +53,18 @@ EmoteFFZ _$EmoteFFZFromJson(Map<String, dynamic> json) => EmoteFFZ(
|
|||
json['animated'] == null
|
||||
? null
|
||||
: ImagesFFZ.fromJson(json['animated'] as Map<String, dynamic>),
|
||||
);
|
||||
);
|
||||
|
||||
Emote7TV _$Emote7TVFromJson(Map<String, dynamic> json) => Emote7TV(
|
||||
json['id'] as String,
|
||||
json['name'] as String,
|
||||
Emote7TVData.fromJson(json['data'] as Map<String, dynamic>),
|
||||
);
|
||||
);
|
||||
|
||||
Owner7TV _$Owner7TVFromJson(Map<String, dynamic> json) => Owner7TV(
|
||||
username: json['username'] as String,
|
||||
displayName: json['display_name'] as String,
|
||||
);
|
||||
);
|
||||
|
||||
Emote7TVData _$Emote7TVDataFromJson(Map<String, dynamic> json) => Emote7TVData(
|
||||
json['id'] as String,
|
||||
|
|
@ -79,21 +74,21 @@ Emote7TVData _$Emote7TVDataFromJson(Map<String, dynamic> json) => Emote7TVData(
|
|||
? null
|
||||
: Owner7TV.fromJson(json['owner'] as Map<String, dynamic>),
|
||||
Emote7TVHost.fromJson(json['host'] as Map<String, dynamic>),
|
||||
);
|
||||
);
|
||||
|
||||
Emote7TVHost _$Emote7TVHostFromJson(Map<String, dynamic> json) => Emote7TVHost(
|
||||
json['url'] as String,
|
||||
(json['files'] as List<dynamic>)
|
||||
.map((e) => Emote7TVFile.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
);
|
||||
|
||||
Emote7TVFile _$Emote7TVFileFromJson(Map<String, dynamic> json) => Emote7TVFile(
|
||||
json['name'] as String,
|
||||
(json['width'] as num).toInt(),
|
||||
(json['height'] as num).toInt(),
|
||||
json['format'] as String,
|
||||
);
|
||||
);
|
||||
|
||||
Emote _$EmoteFromJson(Map<String, dynamic> json) => Emote(
|
||||
name: json['name'] as String,
|
||||
|
|
@ -106,7 +101,7 @@ Emote _$EmoteFromJson(Map<String, dynamic> json) => Emote(
|
|||
ownerDisplayName: json['ownerDisplayName'] as String?,
|
||||
ownerUsername: json['ownerUsername'] as String?,
|
||||
ownerId: json['ownerId'] as String?,
|
||||
);
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$EmoteToJson(Emote instance) => <String, dynamic>{
|
||||
'name': instance.name,
|
||||
|
|
@ -119,7 +114,7 @@ Map<String, dynamic> _$EmoteToJson(Emote instance) => <String, dynamic>{
|
|||
'ownerDisplayName': instance.ownerDisplayName,
|
||||
'ownerUsername': instance.ownerUsername,
|
||||
'ownerId': instance.ownerId,
|
||||
};
|
||||
};
|
||||
|
||||
const _$EmoteTypeEnumMap = {
|
||||
EmoteType.twitchBits: 'twitchBits',
|
||||
|
|
|
|||
|
|
@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) =>
|
||||
_$SevenTVEventUpdatedEmoteFromJson(json);
|
||||
|
|
|
|||
|
|
@ -10,23 +10,10 @@ SevenTVEvent _$SevenTVEventFromJson(Map<String, dynamic> json) => SevenTVEvent(
|
|||
op: (json['op'] as num).toInt(),
|
||||
t: (json['t'] as num?)?.toInt(),
|
||||
d: SevenTVEventData.fromJson(json['d'] as Map<String, dynamic>),
|
||||
);
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SevenTVEventToJson(SevenTVEvent instance) {
|
||||
final val = <String, dynamic>{
|
||||
'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<String, dynamic> _$SevenTVEventToJson(SevenTVEvent instance) =>
|
||||
<String, dynamic>{'op': instance.op, 't': ?instance.t, 'd': instance.d};
|
||||
|
||||
SevenTVEventData _$SevenTVEventDataFromJson(Map<String, dynamic> json) =>
|
||||
SevenTVEventData(
|
||||
|
|
@ -37,36 +24,32 @@ SevenTVEventData _$SevenTVEventDataFromJson(Map<String, dynamic> json) =>
|
|||
body: json['body'] == null
|
||||
? null
|
||||
: SevenTVEventEmoteSetBody.fromJson(
|
||||
json['body'] as Map<String, dynamic>),
|
||||
json['body'] as Map<String, dynamic>,
|
||||
),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SevenTVEventDataToJson(SevenTVEventData instance) =>
|
||||
<String, dynamic>{
|
||||
'type': instance.type,
|
||||
'condition': instance.condition,
|
||||
};
|
||||
<String, dynamic>{'type': instance.type, 'condition': instance.condition};
|
||||
|
||||
SevenTVEventUpdatedEmote _$SevenTVEventUpdatedEmoteFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
SevenTVEventUpdatedEmote(
|
||||
Map<String, dynamic> json,
|
||||
) => SevenTVEventUpdatedEmote(
|
||||
value: json['value'] == null
|
||||
? null
|
||||
: Emote7TV.fromJson(json['value'] as Map<String, dynamic>),
|
||||
oldValue: json['old_value'] == null
|
||||
? null
|
||||
: Emote7TV.fromJson(json['old_value'] as Map<String, dynamic>),
|
||||
);
|
||||
);
|
||||
|
||||
SevenTVEventEmoteSetBody _$SevenTVEventEmoteSetBodyFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
SevenTVEventEmoteSetBody(
|
||||
Map<String, dynamic> json,
|
||||
) => SevenTVEventEmoteSetBody(
|
||||
actor: Owner7TV.fromJson(json['actor'] as Map<String, dynamic>),
|
||||
pushed: (json['pushed'] as List<dynamic>?)
|
||||
?.map((e) =>
|
||||
SevenTVEventUpdatedEmote.fromJson(e as Map<String, dynamic>))
|
||||
?.map((e) => SevenTVEventUpdatedEmote.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
pulled: (json['pulled'] as List<dynamic>?)
|
||||
?.map((e) =>
|
||||
SevenTVEventUpdatedEmote.fromJson(e as Map<String, dynamic>))
|
||||
?.map((e) => SevenTVEventUpdatedEmote.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
);
|
||||
|
|
|
|||
39
lib/models/followed_channel.dart
Normal file
39
lib/models/followed_channel.dart
Normal file
|
|
@ -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<String, dynamic> json) =>
|
||||
_$FollowedChannelFromJson(json);
|
||||
}
|
||||
|
||||
// Response object for followed channels API call.
|
||||
@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
|
||||
class FollowedChannels {
|
||||
final List<FollowedChannel> data;
|
||||
final Map<String, String> pagination;
|
||||
final int total;
|
||||
|
||||
const FollowedChannels({
|
||||
required this.data,
|
||||
required this.pagination,
|
||||
required this.total,
|
||||
});
|
||||
|
||||
factory FollowedChannels.fromJson(Map<String, dynamic> json) =>
|
||||
_$FollowedChannelsFromJson(json);
|
||||
}
|
||||
24
lib/models/followed_channel.g.dart
Normal file
24
lib/models/followed_channel.g.dart
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'followed_channel.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
FollowedChannel _$FollowedChannelFromJson(Map<String, dynamic> 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<String, dynamic> json) =>
|
||||
FollowedChannels(
|
||||
data: (json['data'] as List<dynamic>)
|
||||
.map((e) => FollowedChannel.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
pagination: Map<String, String>.from(json['pagination'] as Map),
|
||||
total: (json['total'] as num).toInt(),
|
||||
);
|
||||
|
|
@ -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<String>? 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,44 +127,25 @@ class IRCMessage {
|
|||
}
|
||||
}
|
||||
|
||||
/// Returns an [InlineSpan] list that corresponds to the badges, username, words, and emotes of the given [IRCMessage].
|
||||
List<InlineSpan> generateSpan(
|
||||
BuildContext context, {
|
||||
/// Adds timestamp to the span if enabled
|
||||
void _addTimestamp(
|
||||
List<InlineSpan> 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,
|
||||
Map<String, UserTwitch>? channelIdToUserTwitch,
|
||||
TimestampType timestamp = TimestampType.disabled,
|
||||
}) {
|
||||
final isLightTheme = Theme.of(context).brightness == Brightness.light;
|
||||
TimestampType timestamp,
|
||||
) {
|
||||
if (timestamp == TimestampType.disabled) return;
|
||||
|
||||
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 = <InlineSpan>[];
|
||||
|
||||
if (timestamp != TimestampType.disabled) {
|
||||
final time = tags['tmi-sent-ts'] ??
|
||||
DateTime.now().millisecondsSinceEpoch.toString();
|
||||
final time =
|
||||
tags['tmi-sent-ts'] ?? DateTime.now().millisecondsSinceEpoch.toString();
|
||||
final parsedTime = DateTime.fromMillisecondsSinceEpoch(int.parse(time));
|
||||
|
||||
if (timestamp == TimestampType.twentyFour) {
|
||||
final timeText = timestamp == TimestampType.twentyFour
|
||||
? '${DateFormat.Hm().format(parsedTime)} '
|
||||
: '${DateFormat('h:mm').format(parsedTime)} ';
|
||||
|
||||
span.add(
|
||||
TextSpan(
|
||||
text: '${DateFormat.Hm().format(parsedTime)} ',
|
||||
text: timeText,
|
||||
style: style?.copyWith(
|
||||
color: style.color?.withValues(alpha: 0.5),
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
|
|
@ -158,19 +154,14 @@ class IRCMessage {
|
|||
);
|
||||
}
|
||||
|
||||
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()],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds historical and channel source badges to the span
|
||||
void _addHistoricalAndChannelBadges(
|
||||
BuildContext context,
|
||||
List<InlineSpan> span,
|
||||
double badgeSize,
|
||||
Map<String, UserTwitch>? channelIdToUserTwitch,
|
||||
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<InlineSpan> 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,117 +400,196 @@ class IRCMessage {
|
|||
}
|
||||
}
|
||||
|
||||
return skipBot;
|
||||
}
|
||||
|
||||
/// Adds the username to the span with proper color and styling
|
||||
void _addUsername(
|
||||
List<InlineSpan> 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<InlineSpan> span,
|
||||
Map<String, Emote> 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: ' <message deleted>'));
|
||||
} else {
|
||||
// Check if the message is a reply. If it is, remove the reply username (first word) from the message.
|
||||
span.add(
|
||||
TextSpan(
|
||||
text: ' <message deleted>',
|
||||
style: onTapDeletedMessage != null
|
||||
? textStyle?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
)
|
||||
: null,
|
||||
recognizer: onTapDeletedMessage != null
|
||||
? (TapGestureRecognizer()..onTap = onTapDeletedMessage)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// 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 = <InlineSpan>[];
|
||||
|
||||
// Iterate through the words in reverse-order.
|
||||
// Index starts at the last word in the message.
|
||||
var index = words.length - 1;
|
||||
|
||||
// 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];
|
||||
|
||||
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 {
|
||||
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 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<InlineSpan> localSpan,
|
||||
List<String> words,
|
||||
int startIndex,
|
||||
Emote startEmote,
|
||||
Map<String, Emote> emoteToObject,
|
||||
double emoteSize,
|
||||
double emoteScale,
|
||||
TextStyle? textStyle,
|
||||
bool launchExternal,
|
||||
) {
|
||||
final emoteStack = <Emote>[];
|
||||
var index = startIndex;
|
||||
|
||||
// Handle stacking consecutive zero-width emotes.
|
||||
Emote? nextEmote = emote;
|
||||
Emote? nextEmote = startEmote;
|
||||
while (nextEmote != null && nextEmote.zeroWidth && index != 0) {
|
||||
emoteStack.add(nextEmote);
|
||||
index--;
|
||||
nextEmote =
|
||||
emoteToObject[words[index]] ?? localEmotes?[words[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 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.
|
||||
// Will be used as the children of the Stack widget.
|
||||
final children = [
|
||||
if (nextWordIsEmoji)
|
||||
Text(
|
||||
words[index],
|
||||
style: textStyle?.copyWith(fontSize: emoteSize - 5),
|
||||
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,
|
||||
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 message = emoteStack.reversed.map((emote) => emote.name);
|
||||
final emoji = words[index];
|
||||
|
||||
localSpan.add(
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
|
|
@ -487,15 +600,10 @@ class IRCMessage {
|
|||
alignment: AlignmentDirectional.center,
|
||||
children: [
|
||||
if (nextWordIsEmoji)
|
||||
Text(
|
||||
emoji,
|
||||
style: textStyle?.copyWith(fontSize: 40),
|
||||
),
|
||||
Text(emoji, style: textStyle?.copyWith(fontSize: 40)),
|
||||
...emoteStack.reversed.map(
|
||||
(emote) => FrostyCachedNetworkImage(
|
||||
imageUrl: emote.url,
|
||||
width: 56,
|
||||
),
|
||||
(emote) =>
|
||||
FrostyCachedNetworkImage(imageUrl: emote.url, width: 56),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -516,7 +624,7 @@ class IRCMessage {
|
|||
),
|
||||
);
|
||||
|
||||
// If the next word is neither an emote or emoji, add it as a text span.
|
||||
// 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(
|
||||
|
|
@ -524,51 +632,77 @@ class IRCMessage {
|
|||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
} 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add space after the zero-width emote stack (like all other elements)
|
||||
localSpan.add(const TextSpan(text: ' '));
|
||||
index--;
|
||||
|
||||
return index - 1;
|
||||
}
|
||||
|
||||
// Add the the local span reversed to the final span.
|
||||
span.addAll(localSpan.reversed);
|
||||
}
|
||||
}
|
||||
/// Returns an [InlineSpan] list that corresponds to the badges, username, words, and emotes of the given [IRCMessage].
|
||||
List<InlineSpan> 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<String, UserTwitch>? 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 = <InlineSpan>[];
|
||||
|
||||
_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,
|
||||
|
|
@ -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 = <String, String>{};
|
||||
|
|
@ -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<String>? 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,11 +1145,17 @@ class IRCMessage {
|
|||
);
|
||||
}
|
||||
|
||||
factory IRCMessage.createNotice({required String message}) => IRCMessage(
|
||||
factory IRCMessage.createNotice({
|
||||
required String message,
|
||||
VoidCallback? actionCallback,
|
||||
String? actionLabel,
|
||||
}) => IRCMessage(
|
||||
raw: '',
|
||||
tags: {},
|
||||
command: Command.notice,
|
||||
message: message,
|
||||
actionCallback: actionCallback,
|
||||
actionLabel: actionLabel,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,5 @@ SharedChatSession _$SharedChatSessionFromJson(Map<String, dynamic> json) =>
|
|||
updatedAt: json['updated_at'] as String,
|
||||
);
|
||||
|
||||
Participant _$ParticipantFromJson(Map<String, dynamic> json) => Participant(
|
||||
broadcasterId: json['broadcaster_id'] as String,
|
||||
);
|
||||
Participant _$ParticipantFromJson(Map<String, dynamic> json) =>
|
||||
Participant(broadcasterId: json['broadcaster_id'] as String);
|
||||
|
|
|
|||
|
|
@ -36,10 +36,7 @@ class StreamsTwitch {
|
|||
final List<StreamTwitch> data;
|
||||
final Map<String, String> pagination;
|
||||
|
||||
const StreamsTwitch(
|
||||
this.data,
|
||||
this.pagination,
|
||||
);
|
||||
const StreamsTwitch(this.data, this.pagination);
|
||||
|
||||
factory StreamsTwitch.fromJson(Map<String, dynamic> json) =>
|
||||
_$StreamsTwitchFromJson(json);
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ StreamTwitch _$StreamTwitchFromJson(Map<String, dynamic> json) => StreamTwitch(
|
|||
(json['viewer_count'] as num).toInt(),
|
||||
json['started_at'] as String,
|
||||
json['thumbnail_url'] as String,
|
||||
);
|
||||
);
|
||||
|
||||
StreamsTwitch _$StreamsTwitchFromJson(Map<String, dynamic> json) =>
|
||||
StreamsTwitch(
|
||||
|
|
|
|||
|
|
@ -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<String, dynamic> 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<String, dynamic> json) =>
|
||||
_$UserBlockedTwitchFromJson(json);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ UserTwitch _$UserTwitchFromJson(Map<String, dynamic> json) => UserTwitch(
|
|||
json['login'] as String,
|
||||
json['display_name'] as String,
|
||||
json['profile_image_url'] as String,
|
||||
);
|
||||
);
|
||||
|
||||
UserBlockedTwitch _$UserBlockedTwitchFromJson(Map<String, dynamic> json) =>
|
||||
UserBlockedTwitch(
|
||||
|
|
|
|||
|
|
@ -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<VideoChat> createState() => _VideoChatState();
|
||||
}
|
||||
|
||||
class _VideoChatState extends State<VideoChat> {
|
||||
class _VideoChatState extends State<VideoChat>
|
||||
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
||||
final _videoKey = GlobalKey();
|
||||
final _chatKey = GlobalKey();
|
||||
|
||||
late final ChatStore _chatStore = ChatStore(
|
||||
twitchApi: context.read<TwitchApi>(),
|
||||
channelName: widget.userLogin,
|
||||
channelId: widget.userId,
|
||||
displayName: widget.userName,
|
||||
auth: context.read<AuthStore>(),
|
||||
settings: context.read<SettingsStore>(),
|
||||
chatDetailsStore: ChatDetailsStore(
|
||||
twitchApi: context.read<TwitchApi>(),
|
||||
channelName: widget.userLogin,
|
||||
),
|
||||
assetsStore: ChatAssetsStore(
|
||||
twitchApi: context.read<TwitchApi>(),
|
||||
ffzApi: context.read<FFZApi>(),
|
||||
bttvApi: context.read<BTTVApi>(),
|
||||
sevenTVApi: context.read<SevenTVApi>(),
|
||||
),
|
||||
// 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<double> _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<TwitchApi>(),
|
||||
authStore: context.read<AuthStore>(),
|
||||
settingsStore: context.read<SettingsStore>(),
|
||||
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<double>(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<double>(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<VideoChat> {
|
|||
_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<VideoChat> {
|
|||
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,102 +255,110 @@ class _VideoChatState extends State<VideoChat> {
|
|||
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,
|
||||
chatTabsStore: _chatTabsStore,
|
||||
// In chat-only mode, account for the blurred AppBar height
|
||||
listPadding: chatOnly
|
||||
? EdgeInsets.only(top: context.safePaddingTop)
|
||||
: null,
|
||||
),
|
||||
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(
|
||||
Observer(
|
||||
builder: (_) => 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,
|
||||
alignment: Alignment.topCenter,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: chatOnly ? context.safePaddingTop : 0,
|
||||
),
|
||||
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('');
|
||||
},
|
||||
onDismissed: _chatStore.clearNotification,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final videoChat = Scaffold(
|
||||
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;
|
||||
|
||||
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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
extendBody: true,
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: chatOnlyBlurredAppBar,
|
||||
body: Observer(
|
||||
builder: (context) {
|
||||
if (orientation == Orientation.landscape &&
|
||||
if (context.isLandscape &&
|
||||
!settingsStore.landscapeForceVerticalChat) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||
SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.immersiveSticky,
|
||||
);
|
||||
|
||||
final landscapeChat = AnimatedContainer(
|
||||
curve: Curves.ease,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
duration: _isDividerDragging
|
||||
? Duration.zero
|
||||
: 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
|
||||
? context.screenWidth / 2
|
||||
: context.screenWidth * settingsStore.chatWidth,
|
||||
color: settingsStore.fullScreen
|
||||
? Colors.black.withValues(
|
||||
alpha: _chatStore.settings.fullScreenChatOverlayOpacity,
|
||||
alpha: settingsStore.fullScreenChatOverlayOpacity,
|
||||
)
|
||||
: Theme.of(context).scaffoldBackgroundColor,
|
||||
: context.scaffoldColor,
|
||||
child: chat,
|
||||
);
|
||||
|
||||
|
|
@ -250,12 +370,18 @@ class _VideoChatState extends State<VideoChat> {
|
|||
colorSchemeSeed: Color(settingsStore.accentColor),
|
||||
).dark,
|
||||
child: DefaultTextStyle(
|
||||
style: DefaultTextStyle.of(context).style.copyWith(
|
||||
style: context.defaultTextStyle.copyWith(
|
||||
color: context
|
||||
.watch<FrostyThemes>()
|
||||
.dark
|
||||
.colorScheme
|
||||
.onSurface,
|
||||
shadows: settingsStore.fullScreen && context.isLandscape
|
||||
? const [
|
||||
Shadow(blurRadius: 8),
|
||||
Shadow(blurRadius: 4, offset: Offset(1, 1)),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: landscapeChat,
|
||||
),
|
||||
|
|
@ -265,29 +391,55 @@ class _VideoChatState extends State<VideoChat> {
|
|||
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,
|
||||
: 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
|
||||
children:
|
||||
settingsStore
|
||||
.landscapeChatLeftSide
|
||||
? [
|
||||
overlayChat,
|
||||
Expanded(child: overlay),
|
||||
|
|
@ -297,25 +449,126 @@ class _VideoChatState extends State<VideoChat> {
|
|||
overlayChat,
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left:
|
||||
settingsStore
|
||||
.landscapeChatLeftSide
|
||||
? (totalWidth * chatWidth) -
|
||||
12
|
||||
: null,
|
||||
right:
|
||||
!settingsStore
|
||||
.landscapeChatLeftSide
|
||||
? (totalWidth * chatWidth) -
|
||||
12
|
||||
: null,
|
||||
child: draggableDivider,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
children: settingsStore.landscapeChatLeftSide
|
||||
: 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
|
||||
? [
|
||||
landscapeChat,
|
||||
const VerticalDivider(),
|
||||
chatContainer,
|
||||
Expanded(child: video),
|
||||
]
|
||||
: [
|
||||
Expanded(child: video),
|
||||
const VerticalDivider(),
|
||||
landscapeChat,
|
||||
chatContainer,
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left:
|
||||
settingsStore
|
||||
.landscapeChatLeftSide
|
||||
? (availableWidth * chatWidth) -
|
||||
12
|
||||
: null,
|
||||
right:
|
||||
!settingsStore
|
||||
.landscapeChatLeftSide
|
||||
? (availableWidth * chatWidth) -
|
||||
12
|
||||
: null,
|
||||
child: draggableDivider,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
children: [appBar, Expanded(child: chat)],
|
||||
),
|
||||
),
|
||||
: SafeArea(child: chat),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -324,21 +577,113 @@ class _VideoChatState extends State<VideoChat> {
|
|||
overlays: SystemUiOverlay.values,
|
||||
);
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
// 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: [
|
||||
if (!settingsStore.showVideo)
|
||||
appBar
|
||||
else ...[
|
||||
AspectRatio(aspectRatio: 16 / 9, child: video),
|
||||
const Divider(),
|
||||
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.
|
||||
if (Platform.isAndroid) {
|
||||
|
|
@ -355,10 +700,15 @@ class _VideoChatState extends State<VideoChat> {
|
|||
|
||||
@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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
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)
|
||||
? context.screenHeight / (context.isPortrait ? 3 : 2)
|
||||
: 0,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
switchInCurve: Curves.easeOut,
|
||||
switchOutCurve: Curves.easeIn,
|
||||
child: chatStore.assetsStore.showEmoteMenu
|
||||
? Column(
|
||||
? 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,11 +208,11 @@ class Chat extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<ChatDetails> {
|
||||
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<ChatDetails> {
|
|||
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<ChatDetails> {
|
|||
Duration(hours: 12),
|
||||
];
|
||||
|
||||
return showModalBottomSheet(
|
||||
return showModalBottomSheetWithProperFocus(
|
||||
context: context,
|
||||
builder: (context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
@ -91,12 +107,26 @@ class _ChatDetailsState extends State<ChatDetails> {
|
|||
builder: (context) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.close_rounded),
|
||||
title: Text(
|
||||
'Turn off (${formatTimeLeft(widget.chatStore.timeRemaining)})',
|
||||
style: const TextStyle(
|
||||
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: () {
|
||||
widget.chatStore.cancelSleepTimer();
|
||||
|
||||
|
|
@ -128,49 +158,207 @@ class _ChatDetailsState extends State<ChatDetails> {
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<String?> _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<void> _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<void> _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) {
|
||||
return Observer(
|
||||
builder: (context) {
|
||||
final hasActiveModes = _hasActiveModes();
|
||||
|
||||
final children = [
|
||||
const SectionHeader(
|
||||
'Chat modes',
|
||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
isFirst: true,
|
||||
if (hasActiveModes)
|
||||
ListTile(
|
||||
title: SizedBox(
|
||||
height: 48,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
Observer(
|
||||
builder: (context) => ChatModes(
|
||||
roomState: widget.chatDetailsStore.roomState,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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(
|
||||
title: ChatModes(roomState: widget.chatDetailsStore.roomState),
|
||||
),
|
||||
const SectionHeader(
|
||||
'More',
|
||||
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.people_outline),
|
||||
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'),
|
||||
onTap: () => showModalBottomSheet(
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => showModalBottomSheetWithProperFocus(
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => GestureDetector(
|
||||
|
|
@ -183,58 +371,48 @@ class _ChatDetailsState extends State<ChatDetails> {
|
|||
),
|
||||
),
|
||||
),
|
||||
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: const Icon(Icons.timer_outlined),
|
||||
leading: Icon(
|
||||
hasTimer ? Icons.timer_rounded : Icons.timer_rounded,
|
||||
color: hasTimer
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
'Sleep timer ${widget.chatStore.timeRemaining.inSeconds > 0 ? 'on (${formatTimeLeft(widget.chatStore.timeRemaining)})' : ''}',
|
||||
style: const TextStyle(
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
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.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),
|
||||
leading: const Icon(Icons.settings_rounded),
|
||||
title: const Text('Settings'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
|
|
@ -245,10 +423,8 @@ class _ChatDetailsState extends State<ChatDetails> {
|
|||
),
|
||||
];
|
||||
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
primary: false,
|
||||
children: children,
|
||||
return ListView(shrinkWrap: true, primary: false, children: children);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -258,3 +434,187 @@ class _ChatDetailsState extends State<ChatDetails> {
|
|||
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<void> 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 = <String, String>{
|
||||
'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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,13 +12,16 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store {
|
|||
Computed<Iterable<String>>? _$filteredUsersComputed;
|
||||
|
||||
@override
|
||||
Iterable<String> get filteredUsers => (_$filteredUsersComputed ??=
|
||||
Computed<Iterable<String>>(() => super.filteredUsers,
|
||||
name: 'ChatDetailsStoreBase.filteredUsers'))
|
||||
.value;
|
||||
Iterable<String> get filteredUsers =>
|
||||
(_$filteredUsersComputed ??= Computed<Iterable<String>>(
|
||||
() => 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();
|
||||
|
|
|
|||
|
|
@ -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 = <MapEntry<String, Widget>>[];
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
// 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
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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: [
|
||||
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',
|
||||
Text(isActive ? activeLabel : label),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
duration,
|
||||
style: TextStyle(
|
||||
color: roomState.r9k != '0' ? Colors.purple : disabledColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Text(isActive ? activeLabel : label),
|
||||
side: BorderSide.none,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ChattersList> {
|
|||
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<ChattersList> {
|
|||
child: Stack(
|
||||
alignment: AlignmentDirectional.bottomCenter,
|
||||
children: [
|
||||
Scrollbar(
|
||||
FrostyScrollbar(
|
||||
controller: widget.chatDetailsStore.scrollController,
|
||||
child: Observer(
|
||||
builder: (context) {
|
||||
|
|
@ -108,11 +109,14 @@ class _ChattersListState extends State<ChattersList> {
|
|||
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<ChattersList> {
|
|||
),
|
||||
),
|
||||
onTap: () async {
|
||||
final userInfo =
|
||||
await context.read<TwitchApi>().getUser(
|
||||
headers: context
|
||||
.read<AuthStore>()
|
||||
.headersTwitch,
|
||||
userLogin: widget.chatDetailsStore
|
||||
final userInfo = await context
|
||||
.read<TwitchApi>()
|
||||
.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<ChattersList> {
|
|||
},
|
||||
),
|
||||
itemCount: widget
|
||||
.chatDetailsStore.filteredUsers.length,
|
||||
.chatDetailsStore
|
||||
.filteredUsers
|
||||
.length,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,15 +29,18 @@ class _EmoteMenuSectionState extends State<EmoteMenuSection>
|
|||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
return GridView.builder(
|
||||
return MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeTop: true,
|
||||
child: GridView.builder(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount:
|
||||
MediaQuery.of(context).orientation == Orientation.portrait
|
||||
crossAxisCount: context.isPortrait
|
||||
? 8
|
||||
: context.read<SettingsStore>().showVideo
|
||||
: context.settingsStore.showVideo
|
||||
? 6
|
||||
: 16,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
itemBuilder: (context, index) => InkWell(
|
||||
onTap: widget.disabled
|
||||
? null
|
||||
|
|
@ -69,6 +71,7 @@ class _EmoteMenuSectionState extends State<EmoteMenuSection>
|
|||
),
|
||||
),
|
||||
itemCount: widget.emotes.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,23 +5,50 @@ 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<void> _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(
|
||||
return MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeTop: true,
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
if (chatStore.assetsStore.recentEmotes.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
|
|
@ -31,8 +58,7 @@ class RecentEmotesPanel extends StatelessWidget {
|
|||
else
|
||||
SliverGrid.builder(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount:
|
||||
MediaQuery.of(context).orientation == Orientation.portrait
|
||||
crossAxisCount: context.isPortrait
|
||||
? 8
|
||||
: context.read<SettingsStore>().showVideo
|
||||
? 6
|
||||
|
|
@ -76,7 +102,8 @@ class RecentEmotesPanel extends StatelessWidget {
|
|||
colorBlendMode: matchingEmotes.isNotEmpty
|
||||
? null
|
||||
: BlendMode.modulate,
|
||||
height: emote.height?.toDouble() ?? defaultEmoteSize,
|
||||
height:
|
||||
emote.height?.toDouble() ?? defaultEmoteSize,
|
||||
width: emote.width?.toDouble(),
|
||||
),
|
||||
),
|
||||
|
|
@ -85,7 +112,21 @@ class RecentEmotesPanel extends StatelessWidget {
|
|||
},
|
||||
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'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<String> _loadedSharedChannelIds = <String>{};
|
||||
|
||||
/// Optional: Track 7TV set ids for shared chat participants (keyed by broadcaster id).
|
||||
final sharedSevenTvSetIds = ObservableMap<String, String>();
|
||||
|
||||
final channelIdToUserTwitch = ObservableMap<String, UserTwitch>();
|
||||
|
||||
@computed
|
||||
List<Emote> get bttvEmotes =>
|
||||
_emoteToObject.values.where((emote) => isBTTV(emote)).toList();
|
||||
emoteToObject.values.where((emote) => isBTTV(emote)).toList();
|
||||
|
||||
@computed
|
||||
List<Emote> get ffzEmotes =>
|
||||
_emoteToObject.values.where((emote) => isFFZ(emote)).toList();
|
||||
emoteToObject.values.where((emote) => isFFZ(emote)).toList();
|
||||
|
||||
@computed
|
||||
List<Emote> get sevenTVEmotes =>
|
||||
_emoteToObject.values.where((emote) => is7TV(emote)).toList();
|
||||
|
||||
/// The map of badges ids to their object representation.
|
||||
final twitchBadgesToObject = ObservableMap<String, ChatBadge>();
|
||||
emoteToObject.values.where((emote) => is7TV(emote)).toList();
|
||||
|
||||
@readonly
|
||||
var _recentEmotes = ObservableList<Emote>();
|
||||
|
||||
/// 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<String, Emote>();
|
||||
var _channelEmoteToObject = ObservableMap<String, Emote>();
|
||||
|
||||
/// Channel-specific Twitch badges (overrides globals).
|
||||
@readonly
|
||||
var _channelTwitchBadges = <String, ChatBadge>{};
|
||||
|
||||
/// Combined emotes: global + channel. Channel emotes take precedence.
|
||||
@computed
|
||||
Map<String, Emote> get emoteToObject {
|
||||
final combined = Map<String, Emote>.from(globalAssetsStore.globalEmoteMap);
|
||||
combined.addAll(_channelEmoteToObject);
|
||||
return combined;
|
||||
}
|
||||
|
||||
/// Combined Twitch badges: global + channel. Channel badges take precedence.
|
||||
@computed
|
||||
Map<String, ChatBadge> get twitchBadgesToObject {
|
||||
final combined = Map<String, ChatBadge>.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 = <String, List<Emote>>{};
|
||||
|
||||
/// The map of user IDs to their FFZ badges.
|
||||
@readonly
|
||||
var _userToFFZBadges = <String, List<ChatBadge>>{};
|
||||
/// The map of user IDs to their FFZ badges (from global store).
|
||||
Map<String, List<ChatBadge>> get userToFFZBadges =>
|
||||
globalAssetsStore.ffzBadges;
|
||||
|
||||
/// The map of user IDs to their 7TV badges.
|
||||
@readonly
|
||||
var _userTo7TVBadges = <String, List<ChatBadge>>{};
|
||||
|
||||
/// The map of user IDs to their BTTV badge.
|
||||
@readonly
|
||||
var _userToBTTVBadges = <String, ChatBadge>{};
|
||||
/// The map of user IDs to their BTTV badge (from global store).
|
||||
Map<String, ChatBadge> 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<void> 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) ...[
|
||||
if (showTwitchEmotes)
|
||||
twitchApi
|
||||
.getEmotesGlobal(headers: headers)
|
||||
.catchError(onEmoteError),
|
||||
twitchApi
|
||||
.getEmotesChannel(id: channelId, headers: headers)
|
||||
.getEmotesChannel(id: channelId)
|
||||
.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) {
|
||||
})
|
||||
.catchError(onEmoteError),
|
||||
if (show7TVEmotes)
|
||||
sevenTVApi
|
||||
.getEmotesChannel(id: channelId)
|
||||
.then((data) {
|
||||
final (setId, emotes) = data;
|
||||
sevenTvEmoteSetId = setId;
|
||||
return emotes;
|
||||
}).catchError(onEmoteError),
|
||||
],
|
||||
if (showBTTVEmotes) ...[
|
||||
bttvApi.getEmotesGlobal().catchError(onEmoteError),
|
||||
})
|
||||
.catchError(onEmoteError),
|
||||
if (showBTTVEmotes)
|
||||
bttvApi.getEmotesChannel(id: channelId).catchError(onEmoteError),
|
||||
],
|
||||
if (showFFZEmotes) ...[
|
||||
ffzApi.getEmotesGlobal().catchError(onEmoteError),
|
||||
ffzApi.getRoomInfo(id: channelId).then((ffzRoom) {
|
||||
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) => _emoteToObject = {
|
||||
})
|
||||
.catchError(onEmoteError),
|
||||
])
|
||||
.then((assets) => assets.expand((list) => list))
|
||||
.then(
|
||||
(emotes) => _channelEmoteToObject = <String, Emote>{
|
||||
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) {
|
||||
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.
|
||||
// Channel badges (Twitch only - FFZ/BTTV badges are global)
|
||||
if (showTwitchBadges)
|
||||
twitchApi
|
||||
.getBadgesGlobal(headers: headers)
|
||||
.then((badges) => twitchBadgesToObject.addAll(badges))
|
||||
.then(
|
||||
(_) => twitchApi
|
||||
.getBadgesChannel(id: channelId, headers: headers)
|
||||
.then((badges) => twitchBadgesToObject.addAll(badges))
|
||||
.getBadgesChannel(id: channelId)
|
||||
.then((badges) => _channelTwitchBadges = badges)
|
||||
.catchError(onBadgeError),
|
||||
),
|
||||
if (showFFZBadges)
|
||||
ffzApi
|
||||
.getBadges()
|
||||
.then((badges) => _userToFFZBadges = badges)
|
||||
.catchError(onBadgeError),
|
||||
if (showBTTVBadges)
|
||||
bttvApi
|
||||
.getBadges()
|
||||
.then((badges) => _userToBTTVBadges = 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<void> fetchSharedChatAssets({
|
||||
required String channelId,
|
||||
required Map<String, String> 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 = <Future<void>>[];
|
||||
|
||||
// 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<List<String>> populateSharedChatParticipants({
|
||||
required String channelId,
|
||||
required Function onBadgeError,
|
||||
}) async {
|
||||
SharedChatSession? sharedSession;
|
||||
try {
|
||||
sharedSession = await twitchApi.getSharedChatSession(
|
||||
broadcasterId: channelId,
|
||||
);
|
||||
} catch (e) {
|
||||
onBadgeError(e);
|
||||
return <String>[];
|
||||
}
|
||||
|
||||
if (sharedSession == null) return <String>[];
|
||||
|
||||
final newParticipantIds = <String>[];
|
||||
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<List<Emote>> _fetchEmotesForChannel(
|
||||
String id, {
|
||||
required Function onEmoteError,
|
||||
bool showTwitchEmotes = true,
|
||||
bool show7TVEmotes = true,
|
||||
bool showBTTVEmotes = true,
|
||||
bool showFFZEmotes = true,
|
||||
}) async {
|
||||
final futures = <Future<List<Emote>>>[];
|
||||
|
||||
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<void> _fetchBadgesForChannel(
|
||||
String id, {
|
||||
required Function onBadgeError,
|
||||
}) async {
|
||||
try {
|
||||
final badges = await twitchApi.getBadgesChannel(id: id);
|
||||
_channelTwitchBadges = {..._channelTwitchBadges, ...badges};
|
||||
} catch (e) {
|
||||
onBadgeError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
Future<void> 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],
|
||||
|
|
|
|||
|
|
@ -12,27 +12,46 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store {
|
|||
Computed<List<Emote>>? _$bttvEmotesComputed;
|
||||
|
||||
@override
|
||||
List<Emote> get bttvEmotes =>
|
||||
(_$bttvEmotesComputed ??= Computed<List<Emote>>(() => super.bttvEmotes,
|
||||
name: 'ChatAssetsStoreBase.bttvEmotes'))
|
||||
.value;
|
||||
List<Emote> get bttvEmotes => (_$bttvEmotesComputed ??= Computed<List<Emote>>(
|
||||
() => super.bttvEmotes,
|
||||
name: 'ChatAssetsStoreBase.bttvEmotes',
|
||||
)).value;
|
||||
Computed<List<Emote>>? _$ffzEmotesComputed;
|
||||
|
||||
@override
|
||||
List<Emote> get ffzEmotes =>
|
||||
(_$ffzEmotesComputed ??= Computed<List<Emote>>(() => super.ffzEmotes,
|
||||
name: 'ChatAssetsStoreBase.ffzEmotes'))
|
||||
.value;
|
||||
List<Emote> get ffzEmotes => (_$ffzEmotesComputed ??= Computed<List<Emote>>(
|
||||
() => super.ffzEmotes,
|
||||
name: 'ChatAssetsStoreBase.ffzEmotes',
|
||||
)).value;
|
||||
Computed<List<Emote>>? _$sevenTVEmotesComputed;
|
||||
|
||||
@override
|
||||
List<Emote> get sevenTVEmotes => (_$sevenTVEmotesComputed ??=
|
||||
Computed<List<Emote>>(() => super.sevenTVEmotes,
|
||||
name: 'ChatAssetsStoreBase.sevenTVEmotes'))
|
||||
.value;
|
||||
List<Emote> get sevenTVEmotes =>
|
||||
(_$sevenTVEmotesComputed ??= Computed<List<Emote>>(
|
||||
() => super.sevenTVEmotes,
|
||||
name: 'ChatAssetsStoreBase.sevenTVEmotes',
|
||||
)).value;
|
||||
Computed<Map<String, Emote>>? _$emoteToObjectComputed;
|
||||
|
||||
late final _$_recentEmotesAtom =
|
||||
Atom(name: 'ChatAssetsStoreBase._recentEmotes', context: context);
|
||||
@override
|
||||
Map<String, Emote> get emoteToObject =>
|
||||
(_$emoteToObjectComputed ??= Computed<Map<String, Emote>>(
|
||||
() => super.emoteToObject,
|
||||
name: 'ChatAssetsStoreBase.emoteToObject',
|
||||
)).value;
|
||||
Computed<Map<String, ChatBadge>>? _$twitchBadgesToObjectComputed;
|
||||
|
||||
@override
|
||||
Map<String, ChatBadge> get twitchBadgesToObject =>
|
||||
(_$twitchBadgesToObjectComputed ??= Computed<Map<String, ChatBadge>>(
|
||||
() => super.twitchBadgesToObject,
|
||||
name: 'ChatAssetsStoreBase.twitchBadgesToObject',
|
||||
)).value;
|
||||
|
||||
late final _$_recentEmotesAtom = Atom(
|
||||
name: 'ChatAssetsStoreBase._recentEmotes',
|
||||
context: context,
|
||||
);
|
||||
|
||||
ObservableList<Emote> 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<String, Emote> get emoteToObject {
|
||||
_$_emoteToObjectAtom.reportRead();
|
||||
return super._emoteToObject;
|
||||
ObservableMap<String, Emote> get channelEmoteToObject {
|
||||
_$_channelEmoteToObjectAtom.reportRead();
|
||||
return super._channelEmoteToObject;
|
||||
}
|
||||
|
||||
@override
|
||||
ObservableMap<String, Emote> get _emoteToObject => emoteToObject;
|
||||
ObservableMap<String, Emote> get _channelEmoteToObject =>
|
||||
channelEmoteToObject;
|
||||
|
||||
@override
|
||||
set _emoteToObject(ObservableMap<String, Emote> value) {
|
||||
_$_emoteToObjectAtom.reportWrite(value, super._emoteToObject, () {
|
||||
super._emoteToObject = value;
|
||||
});
|
||||
set _channelEmoteToObject(ObservableMap<String, Emote> 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<String, ChatBadge> get channelTwitchBadges {
|
||||
_$_channelTwitchBadgesAtom.reportRead();
|
||||
return super._channelTwitchBadges;
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, ChatBadge> get _channelTwitchBadges => channelTwitchBadges;
|
||||
|
||||
@override
|
||||
set _channelTwitchBadges(Map<String, ChatBadge> value) {
|
||||
_$_channelTwitchBadgesAtom.reportWrite(
|
||||
value,
|
||||
super._channelTwitchBadges,
|
||||
() {
|
||||
super._channelTwitchBadges = value;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
late final _$_userEmoteToObjectAtom = Atom(
|
||||
name: 'ChatAssetsStoreBase._userEmoteToObject',
|
||||
context: context,
|
||||
);
|
||||
|
||||
Map<String, Emote> 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<String, List<Emote>> get userEmoteSectionToEmotes {
|
||||
_$_userEmoteSectionToEmotesAtom.reportRead();
|
||||
|
|
@ -99,32 +153,19 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store {
|
|||
|
||||
@override
|
||||
set _userEmoteSectionToEmotes(Map<String, List<Emote>> value) {
|
||||
_$_userEmoteSectionToEmotesAtom
|
||||
.reportWrite(value, super._userEmoteSectionToEmotes, () {
|
||||
_$_userEmoteSectionToEmotesAtom.reportWrite(
|
||||
value,
|
||||
super._userEmoteSectionToEmotes,
|
||||
() {
|
||||
super._userEmoteSectionToEmotes = value;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
late final _$_userToFFZBadgesAtom =
|
||||
Atom(name: 'ChatAssetsStoreBase._userToFFZBadges', context: context);
|
||||
|
||||
Map<String, List<ChatBadge>> get userToFFZBadges {
|
||||
_$_userToFFZBadgesAtom.reportRead();
|
||||
return super._userToFFZBadges;
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, List<ChatBadge>> get _userToFFZBadges => userToFFZBadges;
|
||||
|
||||
@override
|
||||
set _userToFFZBadges(Map<String, List<ChatBadge>> 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<String, List<ChatBadge>> get userTo7TVBadges {
|
||||
_$_userTo7TVBadgesAtom.reportRead();
|
||||
|
|
@ -141,26 +182,10 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store {
|
|||
});
|
||||
}
|
||||
|
||||
late final _$_userToBTTVBadgesAtom =
|
||||
Atom(name: 'ChatAssetsStoreBase._userToBTTVBadges', context: context);
|
||||
|
||||
Map<String, ChatBadge> get userToBTTVBadges {
|
||||
_$_userToBTTVBadgesAtom.reportRead();
|
||||
return super._userToBTTVBadges;
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, ChatBadge> get _userToBTTVBadges => userToBTTVBadges;
|
||||
|
||||
@override
|
||||
set _userToBTTVBadges(Map<String, ChatBadge> 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,32 +200,24 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store {
|
|||
});
|
||||
}
|
||||
|
||||
late final _$initAsyncAction =
|
||||
AsyncAction('ChatAssetsStoreBase.init', context: context);
|
||||
late final _$initAsyncAction = AsyncAction(
|
||||
'ChatAssetsStoreBase.init',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> init() {
|
||||
return _$initAsyncAction.run(() => super.init());
|
||||
}
|
||||
|
||||
late final _$userEmotesFutureAsyncAction =
|
||||
AsyncAction('ChatAssetsStoreBase.userEmotesFuture', context: context);
|
||||
late final _$assetsFutureAsyncAction = AsyncAction(
|
||||
'ChatAssetsStoreBase.assetsFuture',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> userEmotesFuture(
|
||||
{required List<String> emoteSets,
|
||||
required Map<String, String> headers,
|
||||
required Function onError}) {
|
||||
return _$userEmotesFutureAsyncAction.run(() => super.userEmotesFuture(
|
||||
emoteSets: emoteSets, headers: headers, onError: onError));
|
||||
}
|
||||
|
||||
late final _$ChatAssetsStoreBaseActionController =
|
||||
ActionController(name: 'ChatAssetsStoreBase', context: context);
|
||||
|
||||
@override
|
||||
Future<void> assetsFuture(
|
||||
{required String channelId,
|
||||
Future<void> assetsFuture({
|
||||
required String channelId,
|
||||
required Map<String, String> headers,
|
||||
required Function onEmoteError,
|
||||
required Function onBadgeError,
|
||||
|
|
@ -210,11 +227,10 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store {
|
|||
bool showBTTVEmotes = true,
|
||||
bool showBTTVBadges = true,
|
||||
bool showFFZEmotes = true,
|
||||
bool showFFZBadges = true}) {
|
||||
final _$actionInfo = _$ChatAssetsStoreBaseActionController.startAction(
|
||||
name: 'ChatAssetsStoreBase.assetsFuture');
|
||||
try {
|
||||
return super.assetsFuture(
|
||||
bool showFFZBadges = true,
|
||||
}) {
|
||||
return _$assetsFutureAsyncAction.run(
|
||||
() => super.assetsFuture(
|
||||
channelId: channelId,
|
||||
headers: headers,
|
||||
onEmoteError: onEmoteError,
|
||||
|
|
@ -225,10 +241,83 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store {
|
|||
showBTTVEmotes: showBTTVEmotes,
|
||||
showBTTVBadges: showBTTVBadges,
|
||||
showFFZEmotes: showFFZEmotes,
|
||||
showFFZBadges: showFFZBadges);
|
||||
} finally {
|
||||
_$ChatAssetsStoreBaseActionController.endAction(_$actionInfo);
|
||||
showFFZBadges: showFFZBadges,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
late final _$fetchSharedChatAssetsAsyncAction = AsyncAction(
|
||||
'ChatAssetsStoreBase.fetchSharedChatAssets',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> fetchSharedChatAssets({
|
||||
required String channelId,
|
||||
required Map<String, String> 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<List<String>> 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<void> userEmotesFuture({
|
||||
required List<String> emoteSets,
|
||||
required Map<String, String> 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}
|
||||
''';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<IRCMessage>();
|
||||
|
||||
/// The set of message IDs that have been revealed by the user (for deleted messages).
|
||||
final revealedMessageIds = ObservableSet<String>();
|
||||
|
||||
@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 <Emote>[];
|
||||
},
|
||||
onBadgeError: (error) {
|
||||
debugPrint(error.toString());
|
||||
return <ChatBadge>[];
|
||||
},
|
||||
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,17 +488,23 @@ abstract class ChatStoreBase with Store {
|
|||
|
||||
// Fetch the assets used in chat including badges and emotes.
|
||||
@action
|
||||
Future<void> getAssets() async => assetsStore.assetsFuture(
|
||||
channelId: channelId,
|
||||
headers: auth.headersTwitch,
|
||||
onEmoteError: (error) {
|
||||
Future<void> getAssets() async {
|
||||
// Prepare common error handlers to avoid repetition.
|
||||
List<Emote> onEmoteError(dynamic error) {
|
||||
debugPrint(error.toString());
|
||||
return <Emote>[];
|
||||
},
|
||||
onBadgeError: (error) {
|
||||
}
|
||||
|
||||
List<ChatBadge> onBadgeError(dynamic error) {
|
||||
debugPrint(error.toString());
|
||||
return <ChatBadge>[];
|
||||
},
|
||||
}
|
||||
|
||||
final baseAssets = assetsStore.assetsFuture(
|
||||
channelId: channelId,
|
||||
headers: auth.headersTwitch,
|
||||
onEmoteError: onEmoteError,
|
||||
onBadgeError: onBadgeError,
|
||||
showTwitchEmotes: settings.showTwitchEmotes,
|
||||
showTwitchBadges: settings.showTwitchBadges,
|
||||
show7TVEmotes: settings.show7TVEmotes,
|
||||
|
|
@ -450,6 +514,27 @@ abstract class ChatStoreBase with Store {
|
|||
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: onEmoteError,
|
||||
onBadgeError: onBadgeError,
|
||||
showTwitchEmotes: settings.showTwitchEmotes,
|
||||
showTwitchBadges: settings.showTwitchBadges,
|
||||
show7TVEmotes: settings.show7TVEmotes,
|
||||
showBTTVEmotes: settings.showBTTVEmotes,
|
||||
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
|
||||
void resumeScroll() {
|
||||
|
|
@ -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<void> 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 3 seconds.
|
||||
_notification = notificationMessage;
|
||||
_notificationTimer = Timer(
|
||||
const Duration(seconds: 5),
|
||||
() => _notification = null,
|
||||
);
|
||||
}
|
||||
|
||||
// Set the new notification message and create a new timer that will dismiss it after 2 seconds.
|
||||
_notification = notificationMessage;
|
||||
_notificationTimer =
|
||||
Timer(const Duration(seconds: 3), () => _notification = null);
|
||||
/// Clears the current notification immediately.
|
||||
@action
|
||||
void clearNotification() {
|
||||
_notificationTimer?.cancel();
|
||||
_notification = null;
|
||||
}
|
||||
|
||||
/// Updates the sleep timer with the given [duration].
|
||||
|
|
@ -741,9 +1002,7 @@ 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) {
|
||||
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();
|
||||
|
|
@ -753,8 +1012,7 @@ abstract class ChatStoreBase with Store {
|
|||
|
||||
// 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<void> 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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,13 +12,16 @@ mixin _$ChatStore on ChatStoreBase, Store {
|
|||
Computed<List<IRCMessage>>? _$renderMessagesComputed;
|
||||
|
||||
@override
|
||||
List<IRCMessage> get renderMessages => (_$renderMessagesComputed ??=
|
||||
Computed<List<IRCMessage>>(() => super.renderMessages,
|
||||
name: 'ChatStoreBase.renderMessages'))
|
||||
.value;
|
||||
List<IRCMessage> get renderMessages =>
|
||||
(_$renderMessagesComputed ??= Computed<List<IRCMessage>>(
|
||||
() => 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<IRCMessage> 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, () {
|
||||
_$_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, () {
|
||||
_$_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<void> getAssets() {
|
||||
return _$getAssetsAsyncAction.run(() => super.getAssets());
|
||||
}
|
||||
|
||||
late final _$getRecentMessageAsyncAction =
|
||||
AsyncAction('ChatStoreBase.getRecentMessage', context: context);
|
||||
late final _$connectToChatAsyncAction = AsyncAction(
|
||||
'ChatStoreBase.connectToChat',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> connectToChat({bool isReconnect = false}) {
|
||||
return _$connectToChatAsyncAction.run(
|
||||
() => super.connectToChat(isReconnect: isReconnect),
|
||||
);
|
||||
}
|
||||
|
||||
late final _$getRecentMessageAsyncAction = AsyncAction(
|
||||
'ChatStoreBase.getRecentMessage',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> 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 '''
|
||||
|
|
|
|||
388
lib/screens/channel/chat/stores/chat_tabs_store.dart
Normal file
388
lib/screens/channel/chat/stores/chat_tabs_store.dart
Normal file
|
|
@ -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<String, dynamic> json) =>
|
||||
_$PersistedChatTabFromJson(json);
|
||||
Map<String, dynamic> 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<ChatTabInfo>();
|
||||
|
||||
/// 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<ChatTabInfo> 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();
|
||||
}
|
||||
}
|
||||
181
lib/screens/channel/chat/stores/chat_tabs_store.g.dart
Normal file
181
lib/screens/channel/chat/stores/chat_tabs_store.g.dart
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'chat_tabs_store.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
PersistedChatTab _$PersistedChatTabFromJson(Map<String, dynamic> json) =>
|
||||
PersistedChatTab(
|
||||
channelId: json['channelId'] as String,
|
||||
channelLogin: json['channelLogin'] as String,
|
||||
displayName: json['displayName'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PersistedChatTabToJson(PersistedChatTab instance) =>
|
||||
<String, dynamic>{
|
||||
'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<ChatTabInfo>? _$activeTabComputed;
|
||||
|
||||
@override
|
||||
ChatTabInfo get activeTab => (_$activeTabComputed ??= Computed<ChatTabInfo>(
|
||||
() => super.activeTab,
|
||||
name: 'ChatTabsStoreBase.activeTab',
|
||||
)).value;
|
||||
Computed<ChatStore>? _$activeChatStoreComputed;
|
||||
|
||||
@override
|
||||
ChatStore get activeChatStore =>
|
||||
(_$activeChatStoreComputed ??= Computed<ChatStore>(
|
||||
() => super.activeChatStore,
|
||||
name: 'ChatTabsStoreBase.activeChatStore',
|
||||
)).value;
|
||||
Computed<bool>? _$canAddTabComputed;
|
||||
|
||||
@override
|
||||
bool get canAddTab => (_$canAddTabComputed ??= Computed<bool>(
|
||||
() => super.canAddTab,
|
||||
name: 'ChatTabsStoreBase.canAddTab',
|
||||
)).value;
|
||||
Computed<bool>? _$showTabBarComputed;
|
||||
|
||||
@override
|
||||
bool get showTabBar => (_$showTabBarComputed ??= Computed<bool>(
|
||||
() => super.showTabBar,
|
||||
name: 'ChatTabsStoreBase.showTabBar',
|
||||
)).value;
|
||||
|
||||
late final _$_tabsAtom = Atom(
|
||||
name: 'ChatTabsStoreBase._tabs',
|
||||
context: context,
|
||||
);
|
||||
|
||||
ObservableList<ChatTabInfo> get tabs {
|
||||
_$_tabsAtom.reportRead();
|
||||
return super._tabs;
|
||||
}
|
||||
|
||||
@override
|
||||
ObservableList<ChatTabInfo> get _tabs => tabs;
|
||||
|
||||
@override
|
||||
set _tabs(ObservableList<ChatTabInfo> 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}
|
||||
''';
|
||||
}
|
||||
}
|
||||
256
lib/screens/channel/chat/widgets/add_chat_dialog.dart
Normal file
256
lib/screens/channel/chat/widgets/add_chat_dialog.dart
Normal file
|
|
@ -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<AddChatResult?> show(
|
||||
BuildContext context,
|
||||
TwitchApi twitchApi,
|
||||
) {
|
||||
return showModalBottomSheetWithProperFocus<AddChatResult>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => AddChatSheet(twitchApi: twitchApi),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<AddChatSheet> createState() => _AddChatSheetState();
|
||||
}
|
||||
|
||||
class _AddChatSheetState extends State<AddChatSheet> {
|
||||
final _textController = TextEditingController();
|
||||
final _focusNode = FocusNode();
|
||||
final _scrollController = ScrollController();
|
||||
|
||||
Timer? _debounce;
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
List<ChannelQuery> _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;
|
||||
|
|
@ -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,7 +55,8 @@ class ChatBottomBar extends StatelessWidget {
|
|||
|
||||
return Observer(
|
||||
builder: (context) {
|
||||
final matchingEmotes = [
|
||||
final matchingEmotes =
|
||||
[
|
||||
...chatStore.assetsStore.userEmoteToObject.values,
|
||||
...chatStore.assetsStore.bttvEmotes,
|
||||
...chatStore.assetsStore.ffzEmotes,
|
||||
|
|
@ -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(),
|
||||
),
|
||||
|
|
@ -187,35 +208,83 @@ class ChatBottomBar extends StatelessWidget {
|
|||
if (!chatStore.expandChat &&
|
||||
chatStore.settings.chatWidth < 0.3 &&
|
||||
chatStore.settings.showVideo &&
|
||||
MediaQuery.of(context).orientation ==
|
||||
Orientation.landscape)
|
||||
IconButton(
|
||||
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: () {
|
||||
onPressed: isDisabled
|
||||
? null
|
||||
: () {
|
||||
chatStore.expandChat = true;
|
||||
chatStore.textFieldFocusNode.requestFocus();
|
||||
chatStore.safeRequestFocus();
|
||||
},
|
||||
icon: const Icon(Icons.edit),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
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,
|
||||
enabled: chatStore.auth.isLoggedIn ? true : false,
|
||||
// Disable text field when sending message, when not logged in, or when chat delay is active
|
||||
enabled: !isDisabled,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: chatStore.settings.emoteMenuButtonOnLeft
|
||||
prefixIcon:
|
||||
chatStore.settings.emoteMenuButtonOnLeft
|
||||
? emoteMenuButton
|
||||
: null,
|
||||
suffixIcon: chatStore.settings.emoteMenuButtonOnLeft
|
||||
? null
|
||||
: emoteMenuButton,
|
||||
suffixIcon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (!chatStore
|
||||
.settings
|
||||
.emoteMenuButtonOnLeft &&
|
||||
emoteMenuButton != 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',
|
||||
? chatStore.isSendingMessage
|
||||
? 'Sending...'
|
||||
: chatStore.replyingToMessage != null
|
||||
? 'Reply'
|
||||
: 'Chat'
|
||||
: loginTooltipMessage,
|
||||
),
|
||||
controller: chatStore.textController,
|
||||
onSubmitted: chatStore.sendMessage,
|
||||
|
|
@ -223,31 +292,52 @@ class ChatBottomBar extends StatelessWidget {
|
|||
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)
|
||||
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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TwitchApi>();
|
||||
final authStore = context.read<AuthStore>();
|
||||
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(
|
||||
|
|
@ -158,7 +183,8 @@ class ChatMessage extends StatelessWidget {
|
|||
final shouldHighlightFirstMessage =
|
||||
chatStore.settings.highlightFirstTimeChatter &&
|
||||
ircMessage.tags['first-msg'] == '1';
|
||||
final shouldHighlightMessage = chatStore.settings.showUserNotices &&
|
||||
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,7 +222,7 @@ class ChatMessage extends StatelessWidget {
|
|||
messageHeader = GestureDetector(
|
||||
onTap: isModal
|
||||
? null
|
||||
: () => showModalBottomSheet(
|
||||
: () => showModalBottomSheetWithProperFocus(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return ReplyThread(
|
||||
|
|
@ -205,14 +231,25 @@ class ChatMessage extends StatelessWidget {
|
|||
);
|
||||
},
|
||||
),
|
||||
child: Text(
|
||||
'Replying to @$replyUser: $replyBody',
|
||||
child: Text.rich(
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
overflow: TextOverflow.ellipsis,
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: replyUser,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: messageHeaderTextColor,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: ': $replyBody',
|
||||
style: TextStyle(color: messageHeaderTextColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (shouldHighlightFirstMessage) {
|
||||
highlightColor = Colors.purple;
|
||||
|
|
@ -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:
|
||||
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();
|
||||
}
|
||||
|
||||
final paddedMessage = Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: chatStore.settings.messageSpacing / 2,
|
||||
horizontal: highlightColor == null ? 12 : 0,
|
||||
// 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,
|
||||
),
|
||||
child: renderMessage,
|
||||
),
|
||||
Expanded(child: renderMessage),
|
||||
],
|
||||
)
|
||||
: renderMessage;
|
||||
|
||||
final paddedMessage = Padding(
|
||||
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: 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;
|
||||
|
||||
|
|
|
|||
216
lib/screens/channel/chat/widgets/chat_tabs.dart
Normal file
216
lib/screens/channel/chat/widgets/chat_tabs.dart
Normal file
|
|
@ -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<void> _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<void> _confirmRemoveTab(
|
||||
BuildContext context,
|
||||
int index,
|
||||
String displayName,
|
||||
) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ChatUserModal> {
|
|||
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<ChatUserModal> {
|
|||
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<ChatUserModal> {
|
|||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
const Divider(indent: 12, endIndent: 12),
|
||||
Expanded(
|
||||
child: Observer(
|
||||
builder: (context) {
|
||||
|
|
@ -103,9 +103,10 @@ class _ChatUserModalState extends State<ChatUserModal> {
|
|||
),
|
||||
),
|
||||
child: DefaultTextStyle(
|
||||
style: DefaultTextStyle.of(context)
|
||||
.style
|
||||
.copyWith(fontSize: widget.chatStore.settings.fontSize),
|
||||
style: DefaultTextStyle.of(context).style.copyWith(
|
||||
fontSize: widget.chatStore.settings.fontSize,
|
||||
),
|
||||
child: FrostyScrollbar(
|
||||
child: ListView.builder(
|
||||
reverse: true,
|
||||
primary: false,
|
||||
|
|
@ -117,6 +118,7 @@ class _ChatUserModalState extends State<ChatUserModal> {
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,22 +55,17 @@ 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: FrostyScrollbar(
|
||||
child: ListView(
|
||||
primary: false,
|
||||
children: chatStore.messages
|
||||
|
|
@ -82,6 +78,7 @@ class ReplyThread extends StatelessWidget {
|
|||
(message) => ChatMessage(
|
||||
isModal: true,
|
||||
showReplyHeader: false,
|
||||
isInReplyThread: true,
|
||||
ircMessage: message,
|
||||
chatStore: chatStore,
|
||||
),
|
||||
|
|
@ -89,6 +86,7 @@ class ReplyThread extends StatelessWidget {
|
|||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
349
lib/screens/channel/video/stream_info_bar.dart
Normal file
349
lib/screens/channel/video/stream_info_bar.dart
Normal file
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Video> createState() => _VideoState();
|
||||
|
|
@ -24,8 +21,9 @@ class _VideoState extends State<Video> with WidgetsBindingObserver {
|
|||
void initState() {
|
||||
super.initState();
|
||||
if (widget.videoStore.settingsStore.showVideo) {
|
||||
widget.videoStore.videoWebViewController
|
||||
.loadRequest(Uri.parse(widget.videoStore.videoUrl));
|
||||
widget.videoStore.videoWebViewController.loadRequest(
|
||||
Uri.parse(widget.videoStore.videoUrl),
|
||||
);
|
||||
}
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
|
@ -62,8 +60,9 @@ class _VideoState extends State<Video> with WidgetsBindingObserver {
|
|||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
widget.videoStore.videoWebViewController
|
||||
.loadRequest(Uri.parse('about:blank'));
|
||||
widget.videoStore.videoWebViewController.loadRequest(
|
||||
Uri.parse('about:blank'),
|
||||
);
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,119 +0,0 @@
|
|||
import 'package:flutter/material.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/widgets/profile_picture.dart';
|
||||
|
||||
class VideoBar extends StatelessWidget {
|
||||
final StreamTwitch streamInfo;
|
||||
final bool showCategory;
|
||||
final bool tappableCategory;
|
||||
final Color? titleTextColor;
|
||||
final Color? subtitleTextColor;
|
||||
final FontWeight? subtitleTextWeight;
|
||||
final EdgeInsets padding;
|
||||
|
||||
const VideoBar({
|
||||
super.key,
|
||||
required this.streamInfo,
|
||||
this.showCategory = true,
|
||||
this.tappableCategory = true,
|
||||
this.titleTextColor,
|
||||
this.subtitleTextColor,
|
||||
this.subtitleTextWeight,
|
||||
this.padding = const EdgeInsets.all(12),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final streamTitle = streamInfo.title.trim();
|
||||
final category =
|
||||
streamInfo.gameName.isNotEmpty ? streamInfo.gameName : 'No Category';
|
||||
|
||||
final streamerName =
|
||||
getReadableName(streamInfo.userName, streamInfo.userLogin);
|
||||
|
||||
return Padding(
|
||||
padding: padding,
|
||||
child: Row(
|
||||
children: [
|
||||
ProfilePicture(
|
||||
userLogin: streamInfo.userLogin,
|
||||
radius: 28,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Tooltip(
|
||||
message: 'Streamer: $streamerName',
|
||||
showDuration: const Duration(seconds: 3),
|
||||
child: Text(
|
||||
streamerName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
color: titleTextColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Tooltip(
|
||||
message: 'Title: ${streamInfo.title.trim()}',
|
||||
showDuration: const Duration(seconds: 5),
|
||||
child: Text(
|
||||
streamTitle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: subtitleTextColor ??
|
||||
Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.8),
|
||||
fontWeight: subtitleTextWeight,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showCategory) ...[
|
||||
const SizedBox(height: 2),
|
||||
InkWell(
|
||||
onTap: tappableCategory && streamInfo.gameName.isNotEmpty
|
||||
? () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CategoryStreams(
|
||||
categoryId: streamInfo.gameId,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
child: Tooltip(
|
||||
message:
|
||||
'Category: ${streamInfo.gameName.isNotEmpty ? streamInfo.gameName : 'None'}',
|
||||
showDuration: const Duration(seconds: 3),
|
||||
child: Text(
|
||||
category,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: subtitleTextColor ??
|
||||
Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.8),
|
||||
fontWeight: subtitleTextWeight,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,11 +5,14 @@ import 'package:flutter/services.dart';
|
|||
import 'package:flutter_mobx/flutter_mobx.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/channel/video/video_bar.dart';
|
||||
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
|
||||
import 'package:frosty/screens/channel/video/video_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
import 'package:frosty/theme.dart';
|
||||
import 'package:frosty/utils.dart';
|
||||
import 'package:frosty/utils/context_extensions.dart';
|
||||
import 'package:frosty/utils/modal_bottom_sheet.dart';
|
||||
import 'package:frosty/widgets/live_indicator.dart';
|
||||
import 'package:frosty/widgets/section_header.dart';
|
||||
import 'package:frosty/widgets/uptime.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
|
@ -29,18 +32,36 @@ class VideoOverlay extends StatelessWidget {
|
|||
required this.settingsStore,
|
||||
});
|
||||
|
||||
static const _iconShadow = [
|
||||
Shadow(
|
||||
offset: Offset(0, 1),
|
||||
blurRadius: 4,
|
||||
color: Color.fromRGBO(0, 0, 0, 0.3),
|
||||
),
|
||||
];
|
||||
|
||||
static const _textShadow = [
|
||||
Shadow(
|
||||
offset: Offset(0, 1),
|
||||
blurRadius: 4,
|
||||
color: Color.fromRGBO(0, 0, 0, 0.3),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final orientation = MediaQuery.of(context).orientation;
|
||||
|
||||
final surfaceColor =
|
||||
context.watch<FrostyThemes>().dark.colorScheme.onSurface;
|
||||
final surfaceColor = context
|
||||
.watch<FrostyThemes>()
|
||||
.dark
|
||||
.colorScheme
|
||||
.onSurface;
|
||||
|
||||
final backButton = IconButton(
|
||||
tooltip: 'Back',
|
||||
icon: Icon(
|
||||
Icons.adaptive.arrow_back_rounded,
|
||||
color: surfaceColor,
|
||||
shadows: _iconShadow,
|
||||
),
|
||||
onPressed: Navigator.of(context).pop,
|
||||
);
|
||||
|
|
@ -53,18 +74,18 @@ class VideoOverlay extends StatelessWidget {
|
|||
onPressed: () => videoStore.settingsStore.fullScreenChatOverlay =
|
||||
!videoStore.settingsStore.fullScreenChatOverlay,
|
||||
icon: videoStore.settingsStore.fullScreenChatOverlay
|
||||
? const Icon(Icons.chat_rounded)
|
||||
: const Icon(Icons.chat_outlined),
|
||||
? Icon(Icons.chat_rounded, shadows: _iconShadow)
|
||||
: Icon(Icons.chat_outlined, shadows: _iconShadow),
|
||||
color: surfaceColor,
|
||||
),
|
||||
);
|
||||
|
||||
final videoSettingsButton = IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
icon: Icon(Icons.settings, shadows: _iconShadow),
|
||||
color: surfaceColor,
|
||||
onPressed: () {
|
||||
videoStore.updateStreamQualities();
|
||||
showModalBottomSheet(
|
||||
showModalBottomSheetWithProperFocus(
|
||||
context: context,
|
||||
builder: (context) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
|
@ -109,35 +130,6 @@ class VideoOverlay extends StatelessWidget {
|
|||
},
|
||||
);
|
||||
|
||||
final latencyTooltip = Tooltip(
|
||||
message: 'Latency to broadcaster',
|
||||
preferBelow: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Observer(
|
||||
builder: (context) => Text(
|
||||
videoStore.latency ?? 'N/A',
|
||||
style: TextStyle(
|
||||
color: surfaceColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
Icon(
|
||||
Icons.speed_rounded,
|
||||
color: surfaceColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final refreshButton = Tooltip(
|
||||
message: 'Refresh',
|
||||
preferBelow: false,
|
||||
|
|
@ -145,6 +137,7 @@ class VideoOverlay extends StatelessWidget {
|
|||
icon: Icon(
|
||||
Icons.refresh_rounded,
|
||||
color: surfaceColor,
|
||||
shadows: _iconShadow,
|
||||
),
|
||||
onPressed: videoStore.handleRefresh,
|
||||
),
|
||||
|
|
@ -161,6 +154,7 @@ class VideoOverlay extends StatelessWidget {
|
|||
? Icons.fullscreen_exit_rounded
|
||||
: Icons.fullscreen_rounded,
|
||||
color: surfaceColor,
|
||||
shadows: _iconShadow,
|
||||
),
|
||||
onPressed: () => videoStore.settingsStore.fullScreen =
|
||||
!videoStore.settingsStore.fullScreen,
|
||||
|
|
@ -168,7 +162,7 @@ class VideoOverlay extends StatelessWidget {
|
|||
);
|
||||
|
||||
final rotateButton = Tooltip(
|
||||
message: orientation == Orientation.portrait
|
||||
message: context.isPortrait
|
||||
? 'Enter landscape mode'
|
||||
: 'Exit landscape mode',
|
||||
preferBelow: false,
|
||||
|
|
@ -176,17 +170,18 @@ class VideoOverlay extends StatelessWidget {
|
|||
icon: Icon(
|
||||
Icons.screen_rotation_rounded,
|
||||
color: surfaceColor,
|
||||
shadows: _iconShadow,
|
||||
),
|
||||
onPressed: () {
|
||||
if (orientation == Orientation.portrait) {
|
||||
if (context.isPortrait) {
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
]);
|
||||
} else {
|
||||
SystemChrome.setPreferredOrientations(
|
||||
[DeviceOrientation.portraitUp],
|
||||
);
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
]);
|
||||
SystemChrome.setPreferredOrientations([]);
|
||||
}
|
||||
},
|
||||
|
|
@ -196,16 +191,127 @@ class VideoOverlay extends StatelessWidget {
|
|||
return Observer(
|
||||
builder: (context) {
|
||||
final streamInfo = videoStore.streamInfo;
|
||||
final offlineChannelInfo = videoStore.offlineChannelInfo;
|
||||
|
||||
// Top gradient - fades from top to bottom, covers top cluster area
|
||||
final topGradient = BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black, // Solid black for controls
|
||||
Colors.black.withValues(alpha: 0.95), // Strong coverage
|
||||
Colors.black.withValues(alpha: 0.88), // Still very strong
|
||||
Colors.black.withValues(alpha: 0.78), // Strong transition
|
||||
Colors.black.withValues(alpha: 0.65), // Begin smooth fade
|
||||
Colors.black.withValues(alpha: 0.48), // Faster fade
|
||||
Colors.black.withValues(alpha: 0.32), // Quick transition
|
||||
Colors.black.withValues(alpha: 0.18), // Rapid fade
|
||||
Colors.black.withValues(alpha: 0.08), // Very light
|
||||
Colors.black.withValues(alpha: 0.02), // Nearly gone
|
||||
Colors.transparent, // Transparent end
|
||||
],
|
||||
stops: [
|
||||
0.0, // Top: Full black - solid area for controls
|
||||
0.1, // Maintain strong coverage for readability
|
||||
0.2, // Still strong black
|
||||
0.3, // Begin gradual fade
|
||||
0.42, // Smooth transition
|
||||
0.52, // Faster fade point
|
||||
0.62, // Quick transition
|
||||
0.7, // Rapid fade
|
||||
0.8, // Very light
|
||||
0.9, // Nearly gone
|
||||
1.0, // Bottom: Fully transparent
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// Bottom gradient - fades from bottom to top, covers bottom cluster area
|
||||
final bottomGradient = BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
Colors.black, // Solid black for controls
|
||||
Colors.black.withValues(alpha: 0.95), // Strong coverage
|
||||
Colors.black.withValues(alpha: 0.88), // Still very strong
|
||||
Colors.black.withValues(alpha: 0.78), // Strong transition
|
||||
Colors.black.withValues(alpha: 0.65), // Begin smooth fade
|
||||
Colors.black.withValues(alpha: 0.48), // Faster fade
|
||||
Colors.black.withValues(alpha: 0.32), // Quick transition
|
||||
Colors.black.withValues(alpha: 0.18), // Rapid fade
|
||||
Colors.black.withValues(alpha: 0.08), // Very light
|
||||
Colors.black.withValues(alpha: 0.02), // Nearly gone
|
||||
Colors.transparent, // Transparent end
|
||||
],
|
||||
stops: [
|
||||
0.0, // Bottom: Full black - solid area for controls
|
||||
0.1, // Maintain strong coverage for readability
|
||||
0.2, // Still strong black
|
||||
0.3, // Begin gradual fade
|
||||
0.42, // Smooth transition
|
||||
0.52, // Faster fade point
|
||||
0.62, // Quick transition
|
||||
0.7, // Rapid fade
|
||||
0.8, // Very light
|
||||
0.9, // Nearly gone
|
||||
1.0, // Top: Fully transparent
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (streamInfo == null) {
|
||||
return Stack(
|
||||
children: [
|
||||
// Top gradient behind top cluster
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 100, // Covers top area around controls (extended)
|
||||
child: Container(decoration: topGradient),
|
||||
),
|
||||
// Bottom gradient behind bottom cluster
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 80, // Covers bottom area around controls
|
||||
child: Container(decoration: bottomGradient),
|
||||
),
|
||||
// Content
|
||||
Stack(
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
backButton,
|
||||
const Spacer(),
|
||||
if (offlineChannelInfo != null)
|
||||
Flexible(
|
||||
child: StreamInfoBar(
|
||||
offlineChannelInfo: offlineChannelInfo,
|
||||
displayName: chatStore.displayName,
|
||||
showUptime: false,
|
||||
showViewerCount: false,
|
||||
showOfflineIndicator: false,
|
||||
textColor: surfaceColor,
|
||||
isOffline: true,
|
||||
isInSharedChatMode:
|
||||
chatStore.isInSharedChatMode,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (videoStore.settingsStore.fullScreen &&
|
||||
orientation == Orientation.landscape)
|
||||
context.isLandscape)
|
||||
chatOverlayButton,
|
||||
],
|
||||
),
|
||||
|
|
@ -218,46 +324,67 @@ class VideoOverlay extends StatelessWidget {
|
|||
// On iPad, hide the rotate button on the overlay
|
||||
// Flutter doesn't allow programmatic rotation on iPad unless multitasking is disabled.
|
||||
if (!isIPad()) rotateButton,
|
||||
if (orientation == Orientation.landscape) fullScreenButton,
|
||||
if (context.isLandscape) fullScreenButton,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Top gradient behind top cluster
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 100, // Covers top area around controls (extended)
|
||||
child: Container(decoration: topGradient),
|
||||
),
|
||||
// Bottom gradient behind bottom cluster
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 80, // Covers bottom area around controls
|
||||
child: Container(decoration: bottomGradient),
|
||||
),
|
||||
// Content
|
||||
Stack(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
backButton,
|
||||
if (orientation == Orientation.landscape)
|
||||
Expanded(
|
||||
child: VideoBar(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
backButton,
|
||||
Flexible(
|
||||
child: Observer(
|
||||
builder: (context) => StreamInfoBar(
|
||||
streamInfo: streamInfo,
|
||||
titleTextColor: surfaceColor,
|
||||
subtitleTextColor: surfaceColor,
|
||||
subtitleTextWeight: FontWeight.w500,
|
||||
showUptime: false,
|
||||
showViewerCount: false,
|
||||
textColor: surfaceColor,
|
||||
isInSharedChatMode:
|
||||
chatStore.isInSharedChatMode,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (videoStore.settingsStore.fullScreen &&
|
||||
orientation == Orientation.landscape)
|
||||
context.isLandscape)
|
||||
chatOverlayButton,
|
||||
if (!Platform.isIOS || isIPad())
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
videoStore.streamQuality,
|
||||
style: TextStyle(
|
||||
color: surfaceColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
videoSettingsButton,
|
||||
],
|
||||
),
|
||||
if (!Platform.isIOS || isIPad()) videoSettingsButton,
|
||||
],
|
||||
),
|
||||
Center(
|
||||
|
|
@ -271,6 +398,13 @@ class VideoOverlay extends StatelessWidget {
|
|||
? Icons.play_arrow_rounded
|
||||
: Icons.pause_rounded,
|
||||
color: surfaceColor,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: const Offset(0, 3),
|
||||
blurRadius: 8,
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
onPressed: videoStore.handlePausePlay,
|
||||
),
|
||||
|
|
@ -279,39 +413,42 @@ class VideoOverlay extends StatelessWidget {
|
|||
Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Tooltip(
|
||||
message: 'Stream uptime',
|
||||
preferBelow: false,
|
||||
triggerMode: TooltipTriggerMode.tap,
|
||||
child: Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.circle,
|
||||
color: Colors.red,
|
||||
size: 10,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const LiveIndicator(),
|
||||
Uptime(
|
||||
startTime: streamInfo.startedAt,
|
||||
style: TextStyle(
|
||||
color: surfaceColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
shadows: _textShadow,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Tooltip(
|
||||
message: 'Viewer count',
|
||||
preferBelow: false,
|
||||
child: GestureDetector(
|
||||
onTap: () => showModalBottomSheet(
|
||||
onTap: () =>
|
||||
showModalBottomSheetWithProperFocus(
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => GestureDetector(
|
||||
|
|
@ -325,13 +462,14 @@ class VideoOverlay extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
child: Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.visibility,
|
||||
size: 14,
|
||||
shadows: _iconShadow,
|
||||
color: surfaceColor,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
NumberFormat().format(
|
||||
videoStore.streamInfo?.viewerCount,
|
||||
|
|
@ -342,39 +480,81 @@ class VideoOverlay extends StatelessWidget {
|
|||
fontFeatures: const [
|
||||
FontFeature.tabularFigures(),
|
||||
],
|
||||
shadows: _textShadow,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (orientation == Orientation.landscape &&
|
||||
settingsStore.showLatency)
|
||||
latencyTooltip,
|
||||
Tooltip(
|
||||
message: 'Enter picture-in-picture',
|
||||
message: 'Latency to broadcaster',
|
||||
preferBelow: false,
|
||||
triggerMode: TooltipTriggerMode.tap,
|
||||
child: Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.speed_rounded,
|
||||
size: 14,
|
||||
color: surfaceColor,
|
||||
shadows: _iconShadow,
|
||||
),
|
||||
Observer(
|
||||
builder: (context) => Text(
|
||||
videoStore.latency ?? '—',
|
||||
style: TextStyle(
|
||||
color: surfaceColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontFeatures: const [
|
||||
FontFeature.tabularFigures(),
|
||||
],
|
||||
shadows: _textShadow,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Builder(
|
||||
builder: (_) {
|
||||
// On iOS, show toggle behavior. On Android, always show enter PiP.
|
||||
final isIOS = Platform.isIOS;
|
||||
final showExitState = isIOS && videoStore.isInPipMode;
|
||||
|
||||
return Tooltip(
|
||||
message: showExitState
|
||||
? 'Exit picture-in-picture'
|
||||
: 'Enter picture-in-picture',
|
||||
preferBelow: false,
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
Icons.picture_in_picture_alt_rounded,
|
||||
showExitState
|
||||
? Icons.picture_in_picture_alt_outlined
|
||||
: Icons.picture_in_picture_alt_rounded,
|
||||
color: surfaceColor,
|
||||
shadows: _iconShadow,
|
||||
),
|
||||
onPressed: videoStore.requestPictureInPicture,
|
||||
onPressed: videoStore.togglePictureInPicture,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
refreshButton,
|
||||
// On iPad, hide the rotate button on the overlay
|
||||
// Flutter doesn't allow programmatic rotation on iPad unless multitasking is disabled.
|
||||
if (!isIPad()) rotateButton,
|
||||
if (orientation == Orientation.landscape) fullScreenButton,
|
||||
if (context.isLandscape) fullScreenButton,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'dart:io';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:frosty/apis/twitch_api.dart';
|
||||
import 'package:frosty/models/channel.dart';
|
||||
import 'package:frosty/models/stream.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
|
|
@ -25,6 +26,9 @@ abstract class VideoStoreBase with Store {
|
|||
/// The userlogin of the current channel.
|
||||
final String userLogin;
|
||||
|
||||
/// The user ID of the current channel.
|
||||
final String userId;
|
||||
|
||||
final AuthStore authStore;
|
||||
|
||||
final SettingsStore settingsStore;
|
||||
|
|
@ -50,8 +54,12 @@ abstract class VideoStoreBase with Store {
|
|||
|
||||
if (!settingsStore.autoSyncChatDelay) return;
|
||||
|
||||
final trimmedLatency = receivedLatency.split(' ')[0];
|
||||
final latencyAsDouble = double.tryParse(trimmedLatency);
|
||||
// Parse latency from abbreviated format: "5s" -> 5.0
|
||||
final numericPart = receivedLatency.replaceAll(
|
||||
RegExp(r'[^0-9.]'),
|
||||
'',
|
||||
);
|
||||
final latencyAsDouble = double.tryParse(numericPart);
|
||||
|
||||
if (latencyAsDouble != null) {
|
||||
settingsStore.chatDelay = latencyAsDouble;
|
||||
|
|
@ -62,8 +70,9 @@ abstract class VideoStoreBase with Store {
|
|||
'StreamQualities',
|
||||
onMessageReceived: (message) async {
|
||||
final data = jsonDecode(message.message) as List;
|
||||
_availableStreamQualities =
|
||||
data.map((item) => item as String).toList();
|
||||
_availableStreamQualities = data
|
||||
.map((item) => item as String)
|
||||
.toList();
|
||||
if (_firstTimeSettingQuality) {
|
||||
_firstTimeSettingQuality = false;
|
||||
if (settingsStore.defaultToHighestQuality) {
|
||||
|
|
@ -89,25 +98,44 @@ abstract class VideoStoreBase with Store {
|
|||
onMessageReceived: (message) {
|
||||
_paused = false;
|
||||
if (Platform.isAndroid) pip.setIsPlaying(true);
|
||||
videoWebViewController.runJavaScript(
|
||||
'document.getElementsByTagName("video")[0].muted = false;',
|
||||
);
|
||||
videoWebViewController.runJavaScript(
|
||||
'document.getElementsByTagName("video")[0].volume = 1.0;',
|
||||
);
|
||||
},
|
||||
)
|
||||
..addJavaScriptChannel(
|
||||
'PipEntered',
|
||||
onMessageReceived: (message) {
|
||||
_overlayWasVisibleBeforePip = _overlayVisible;
|
||||
_isInPipMode = true;
|
||||
_overlayTimer?.cancel();
|
||||
_overlayVisible = true;
|
||||
},
|
||||
)
|
||||
..addJavaScriptChannel(
|
||||
'PipExited',
|
||||
onMessageReceived: (message) {
|
||||
_isInPipMode = false;
|
||||
if (_overlayWasVisibleBeforePip) {
|
||||
_scheduleOverlayHide();
|
||||
} else {
|
||||
_overlayVisible = false;
|
||||
}
|
||||
},
|
||||
)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onPageFinished: (url) async {
|
||||
if (url != videoUrl) return;
|
||||
final injected =
|
||||
(await videoWebViewController.runJavaScriptReturningResult(
|
||||
// Safe evaluation of JavaScript boolean result
|
||||
final result = await videoWebViewController
|
||||
.runJavaScriptReturningResult(
|
||||
'window._injected ? true : false',
|
||||
)) as bool;
|
||||
);
|
||||
final injected = result is bool
|
||||
? result
|
||||
: (result.toString().toLowerCase() == 'true');
|
||||
if (injected) return;
|
||||
await videoWebViewController
|
||||
.runJavaScript('window._injected = true;');
|
||||
await videoWebViewController.runJavaScript(
|
||||
'window._injected = true;',
|
||||
);
|
||||
await initVideo();
|
||||
_acceptContentWarning();
|
||||
},
|
||||
|
|
@ -115,11 +143,23 @@ abstract class VideoStoreBase with Store {
|
|||
);
|
||||
|
||||
/// The timer that handles hiding the overlay automatically
|
||||
late Timer _overlayTimer;
|
||||
Timer? _overlayTimer;
|
||||
|
||||
/// Tracks the pre-PiP overlay visibility so we can restore it on exit.
|
||||
bool _overlayWasVisibleBeforePip = true;
|
||||
|
||||
/// The timer that handles periodic stream info updates
|
||||
Timer? _streamInfoTimer;
|
||||
|
||||
/// Tracks the last time stream info was updated to prevent double refresh
|
||||
DateTime? _lastStreamInfoUpdate;
|
||||
|
||||
/// Disposes the overlay reactions.
|
||||
late final ReactionDisposer _disposeOverlayReaction;
|
||||
|
||||
/// Disposes the video mode reaction for timer management.
|
||||
late final ReactionDisposer _disposeVideoModeReaction;
|
||||
|
||||
ReactionDisposer? _disposeAndroidAutoPipReaction;
|
||||
|
||||
/// If the video is currently paused.
|
||||
|
|
@ -136,6 +176,10 @@ abstract class VideoStoreBase with Store {
|
|||
@readonly
|
||||
StreamTwitch? _streamInfo;
|
||||
|
||||
/// The offline channel info, used for displaying channel details when offline.
|
||||
@readonly
|
||||
Channel? _offlineChannelInfo;
|
||||
|
||||
@readonly
|
||||
List<String> _availableStreamQualities = [];
|
||||
|
||||
|
|
@ -150,16 +194,26 @@ abstract class VideoStoreBase with Store {
|
|||
@readonly
|
||||
String? _latency;
|
||||
|
||||
/// Whether the app is currently in picture-in-picture mode (iOS only).
|
||||
/// On Android, this state is not tracked since there's no programmatic exit.
|
||||
@readonly
|
||||
var _isInPipMode = false;
|
||||
|
||||
/// The video URL to use for the webview.
|
||||
String get videoUrl =>
|
||||
'https://player.twitch.tv/?channel=$userLogin&muted=false&parent=frosty';
|
||||
|
||||
VideoStoreBase({
|
||||
required this.userLogin,
|
||||
required this.userId,
|
||||
required this.twitchApi,
|
||||
required this.authStore,
|
||||
required this.settingsStore,
|
||||
}) {
|
||||
// Reset chat delay to 0 if auto sync is already enabled to prevent starting with old values
|
||||
if (settingsStore.autoSyncChatDelay) {
|
||||
settingsStore.chatDelay = 0.0;
|
||||
}
|
||||
// Initialize the video webview params for iOS to enable video autoplay.
|
||||
if (WebViewPlatform.instance is WebKitWebViewPlatform) {
|
||||
_videoWebViewParams = WebKitWebViewControllerCreationParams(
|
||||
|
|
@ -177,8 +231,7 @@ abstract class VideoStoreBase with Store {
|
|||
}
|
||||
|
||||
// Initialize the [_overlayTimer] to hide the overlay automatically after 5 seconds.
|
||||
_overlayTimer =
|
||||
Timer(const Duration(seconds: 5), () => _overlayVisible = false);
|
||||
_scheduleOverlayHide();
|
||||
|
||||
// Initialize a reaction that will reload the webview whenever the overlay is toggled.
|
||||
_disposeOverlayReaction = reaction(
|
||||
|
|
@ -186,20 +239,41 @@ abstract class VideoStoreBase with Store {
|
|||
(_) => videoWebViewController.loadRequest(Uri.parse(videoUrl)),
|
||||
);
|
||||
|
||||
// Initialize a reaction to manage stream info timer based on video mode
|
||||
_disposeVideoModeReaction = reaction((_) => settingsStore.showVideo, (
|
||||
showVideo,
|
||||
) {
|
||||
if (showVideo) {
|
||||
// In video mode, stop the timer since overlay taps handle refreshing
|
||||
_stopStreamInfoTimer();
|
||||
} else {
|
||||
// In chat-only mode, start the timer for automatic updates
|
||||
_startStreamInfoTimer();
|
||||
// Ensure overlay timer is active for clean UI
|
||||
_scheduleOverlayHide();
|
||||
}
|
||||
});
|
||||
|
||||
// Check initial state and start timer if already in chat-only mode
|
||||
if (!settingsStore.showVideo) {
|
||||
_startStreamInfoTimer();
|
||||
_scheduleOverlayHide();
|
||||
}
|
||||
|
||||
// On Android, enable auto PiP mode (setAutoEnterEnabled) if the device supports it.
|
||||
if (Platform.isAndroid) {
|
||||
_disposeAndroidAutoPipReaction = autorun(
|
||||
(_) async {
|
||||
_disposeAndroidAutoPipReaction = autorun((_) async {
|
||||
if (settingsStore.showVideo && await SimplePip.isAutoPipAvailable) {
|
||||
pip.setAutoPipMode();
|
||||
} else {
|
||||
pip.setAutoPipMode(autoEnter: false);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
updateStreamInfo();
|
||||
|
||||
// Stream info timer will be started when entering chat-only mode
|
||||
}
|
||||
|
||||
@action
|
||||
|
|
@ -236,8 +310,9 @@ abstract class VideoStoreBase with Store {
|
|||
|
||||
@action
|
||||
Future<void> setStreamQuality(String newStreamQuality) async {
|
||||
final indexOfStreamQuality =
|
||||
_availableStreamQualities.indexOf(newStreamQuality);
|
||||
final indexOfStreamQuality = _availableStreamQualities.indexOf(
|
||||
newStreamQuality,
|
||||
);
|
||||
if (indexOfStreamQuality == -1) return;
|
||||
await _setStreamQualityIndex(indexOfStreamQuality);
|
||||
}
|
||||
|
|
@ -273,14 +348,41 @@ abstract class VideoStoreBase with Store {
|
|||
const topBar = document.querySelector(".top-bar");
|
||||
const playerControls = document.querySelector(".player-controls");
|
||||
const channelDisclosures = document.querySelector("#channel-player-disclosures");
|
||||
hideElements(topBar, playerControls, channelDisclosures);
|
||||
const videoPreviewOverlay = document.querySelector('[data-a-target="player-overlay-preview-background"]');
|
||||
hideElements(topBar, playerControls, channelDisclosures, videoPreviewOverlay);
|
||||
}
|
||||
const observer = new MutationObserver(() => {
|
||||
const videoOverlay = document.querySelector('.video-player__overlay');
|
||||
if(!videoOverlay) return;
|
||||
hide();
|
||||
const videoOverlayObserver = new MutationObserver(hide);
|
||||
videoOverlayObserver.observe(videoOverlay, { childList: true, subtree: true });
|
||||
|
||||
// Retry hide() a few times to catch elements that appear with timing variations
|
||||
setTimeout(hide, 300);
|
||||
setTimeout(hide, 800);
|
||||
setTimeout(hide, 1500);
|
||||
|
||||
// Disconnect previous observer if exists to prevent memory leaks
|
||||
if (window._hideOverlayObserver) {
|
||||
window._hideOverlayObserver.disconnect();
|
||||
}
|
||||
|
||||
// Smart observer: only hide when target elements actually appear in mutations
|
||||
window._hideOverlayObserver = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
// Only run hide() if a target element or its container was added
|
||||
if (node.nodeType === Node.ELEMENT_NODE &&
|
||||
(node.classList?.contains('top-bar') ||
|
||||
node.classList?.contains('player-controls') ||
|
||||
node.matches?.('[data-a-target="player-overlay-preview-background"]') ||
|
||||
node.querySelector?.('.top-bar, .player-controls, #channel-player-disclosures, [data-a-target="player-overlay-preview-background"]'))) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
window._hideOverlayObserver.observe(videoOverlay, { childList: true, subtree: true });
|
||||
observer.disconnect();
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
|
@ -309,20 +411,133 @@ abstract class VideoStoreBase with Store {
|
|||
}
|
||||
}
|
||||
|
||||
/// Sets up intermittent stats panel latency tracking.
|
||||
///
|
||||
/// Toggles the stats panel on/off cyclically to minimize CPU overhead.
|
||||
/// The panel is only active ~10% of the time (2s on, 18s off, every 20s).
|
||||
Future<void> _listenOnLatencyChanges() async {
|
||||
try {
|
||||
await videoWebViewController.runJavaScript('''
|
||||
_queuePromise(async () => {
|
||||
(await _asyncQuerySelector('[data-a-target="player-settings-button"]')).click();
|
||||
(await _asyncQuerySelector('[data-a-target="player-settings-menu-item-advanced"]')).click();
|
||||
(await _asyncQuerySelector('[data-a-target="player-settings-submenu-advanced-video-stats"] input')).click();
|
||||
(await _asyncQuerySelector('[data-a-target="player-overlay-video-stats"]')).style.display = "none";
|
||||
(await _asyncQuerySelector('[data-a-target="player-settings-button"]')).click();
|
||||
const observer = new MutationObserver((changes) => {
|
||||
Latency.postMessage(changes[0].target.textContent);
|
||||
})
|
||||
observer.observe(document.querySelector('[aria-label="Latency To Broadcaster"]'), { characterData: true, attributes: false, childList: false, subtree: true });
|
||||
await videoWebViewController.runJavaScript(r'''
|
||||
window._latencyTracker = {
|
||||
CYCLE_INTERVAL: 20000,
|
||||
STATS_ACTIVE_TIME: 2000,
|
||||
INITIAL_RETRY_INTERVAL: 3000,
|
||||
MAX_INITIAL_RETRIES: 4,
|
||||
cycleCount: 0,
|
||||
hasInitialLatency: false,
|
||||
|
||||
async init() {
|
||||
await this._cycleStatsPanel();
|
||||
},
|
||||
|
||||
async _cycleStatsPanel() {
|
||||
this.cycleCount++;
|
||||
const cycleStart = Date.now();
|
||||
|
||||
await this._enableStats();
|
||||
|
||||
// Wait longer on first cycle for stats to populate
|
||||
const waitTime = this.cycleCount === 1 ? 3000 : this.STATS_ACTIVE_TIME;
|
||||
await new Promise(resolve => setTimeout(resolve, waitTime));
|
||||
|
||||
this._readLatency();
|
||||
await this._disableStats();
|
||||
|
||||
const totalActiveTime = Date.now() - cycleStart;
|
||||
|
||||
// Use quick retries until we get initial latency or exceed max retries
|
||||
let nextInterval;
|
||||
if (!this.hasInitialLatency && this.cycleCount < this.MAX_INITIAL_RETRIES) {
|
||||
nextInterval = this.INITIAL_RETRY_INTERVAL;
|
||||
} else {
|
||||
nextInterval = this.CYCLE_INTERVAL - totalActiveTime;
|
||||
}
|
||||
|
||||
setTimeout(() => this._cycleStatsPanel(), nextInterval);
|
||||
},
|
||||
|
||||
async _enableStats() {
|
||||
try {
|
||||
await _queuePromise(async () => {
|
||||
const settingsBtn = await _asyncQuerySelector('[data-a-target="player-settings-button"]');
|
||||
if (!settingsBtn) return;
|
||||
settingsBtn.click();
|
||||
|
||||
const advancedItem = await _asyncQuerySelector('[data-a-target="player-settings-menu-item-advanced"]');
|
||||
if (!advancedItem) {
|
||||
settingsBtn.click();
|
||||
return;
|
||||
}
|
||||
advancedItem.click();
|
||||
|
||||
const statsCheckbox = await _asyncQuerySelector('[data-a-target="player-settings-submenu-advanced-video-stats"] input');
|
||||
if (statsCheckbox && !statsCheckbox.checked) {
|
||||
statsCheckbox.click();
|
||||
}
|
||||
|
||||
const statsOverlay = await _asyncQuerySelector('[data-a-target="player-overlay-video-stats"]');
|
||||
if (statsOverlay) {
|
||||
statsOverlay.style.display = 'none';
|
||||
}
|
||||
|
||||
settingsBtn.click();
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail - stats panel may not be available
|
||||
}
|
||||
},
|
||||
|
||||
async _disableStats() {
|
||||
try {
|
||||
await _queuePromise(async () => {
|
||||
const settingsBtn = await _asyncQuerySelector('[data-a-target="player-settings-button"]');
|
||||
if (!settingsBtn) return;
|
||||
settingsBtn.click();
|
||||
|
||||
const advancedItem = await _asyncQuerySelector('[data-a-target="player-settings-menu-item-advanced"]');
|
||||
if (!advancedItem) {
|
||||
settingsBtn.click();
|
||||
return;
|
||||
}
|
||||
advancedItem.click();
|
||||
|
||||
const statsCheckbox = await _asyncQuerySelector('[data-a-target="player-settings-submenu-advanced-video-stats"] input');
|
||||
if (statsCheckbox && statsCheckbox.checked) {
|
||||
statsCheckbox.click();
|
||||
}
|
||||
|
||||
settingsBtn.click();
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail
|
||||
}
|
||||
},
|
||||
|
||||
_readLatency() {
|
||||
try {
|
||||
const latencyElement = document.querySelector('[aria-label="Latency To Broadcaster"]');
|
||||
if (latencyElement && latencyElement.textContent) {
|
||||
let latencyText = latencyElement.textContent.trim();
|
||||
|
||||
// Convert to whole number with abbreviated unit: "4.69 sec." -> "5s"
|
||||
const match = latencyText.match(/([0-9.]+)\s*sec/i);
|
||||
if (match) {
|
||||
const rounded = Math.round(parseFloat(match[1]));
|
||||
latencyText = rounded + 's';
|
||||
this.hasInitialLatency = true;
|
||||
}
|
||||
|
||||
if (window.Latency) {
|
||||
Latency.postMessage(latencyText);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window._latencyTracker.init();
|
||||
''');
|
||||
} catch (e) {
|
||||
debugPrint(e.toString());
|
||||
|
|
@ -342,41 +557,78 @@ abstract class VideoStoreBase with Store {
|
|||
window._PROMISE_QUEUE = window._PROMISE_QUEUE.then(method, method);
|
||||
return window._PROMISE_QUEUE;
|
||||
};
|
||||
window._asyncQuerySelector = (selector, timeout = undefined) => new Promise((resolve) => {
|
||||
window._asyncQuerySelector = (selector, timeout = 30000) => new Promise((resolve) => {
|
||||
let element = document.querySelector(selector);
|
||||
if (element) {
|
||||
return resolve(element);
|
||||
}
|
||||
|
||||
let timeoutId;
|
||||
const observer = new MutationObserver(() => {
|
||||
element = document.querySelector(selector);
|
||||
if (element) {
|
||||
observer.disconnect();
|
||||
clearTimeout(timeoutId);
|
||||
resolve(element);
|
||||
}
|
||||
});
|
||||
// Must use subtree to detect nested elements in the player
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
if (timeout) {
|
||||
setTimeout(() => {
|
||||
|
||||
// Always set timeout with default of 30 seconds
|
||||
timeoutId = setTimeout(() => {
|
||||
observer.disconnect();
|
||||
resolve(undefined);
|
||||
}, timeout);
|
||||
}
|
||||
});
|
||||
|
||||
_queuePromise(async () => {
|
||||
const videoElement = await _asyncQuerySelector("video");
|
||||
|
||||
// Null check in case element was not found within timeout
|
||||
if (!videoElement) {
|
||||
console.warn("Video element not found within timeout");
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent duplicate event listener registration
|
||||
if (!videoElement._listenersAdded) {
|
||||
videoElement._listenersAdded = true;
|
||||
|
||||
videoElement.addEventListener("pause", () => {
|
||||
VideoPause.postMessage("video paused");
|
||||
if (videoElement.textTracks && videoElement.textTracks.length > 0) {
|
||||
videoElement.textTracks[0].mode = "hidden";
|
||||
}
|
||||
});
|
||||
videoElement.addEventListener("playing", () => {
|
||||
VideoPlaying.postMessage("video playing");
|
||||
// Ensure video is unmuted and captions are hidden
|
||||
videoElement.muted = false;
|
||||
videoElement.volume = 1.0;
|
||||
if (videoElement.textTracks && videoElement.textTracks.length > 0) {
|
||||
videoElement.textTracks[0].mode = "hidden";
|
||||
}
|
||||
});
|
||||
|
||||
// Add PiP event listeners for iOS
|
||||
videoElement.addEventListener("enterpictureinpicture", () => {
|
||||
PipEntered.postMessage("pip entered");
|
||||
});
|
||||
videoElement.addEventListener("leavepictureinpicture", () => {
|
||||
PipExited.postMessage("pip exited");
|
||||
});
|
||||
}
|
||||
|
||||
if (!videoElement.paused) {
|
||||
VideoPlaying.postMessage("video playing");
|
||||
// Ensure video is unmuted and captions are hidden
|
||||
videoElement.muted = false;
|
||||
videoElement.volume = 1.0;
|
||||
if (videoElement.textTracks && videoElement.textTracks.length > 0) {
|
||||
videoElement.textTracks[0].mode = "hidden";
|
||||
}
|
||||
}
|
||||
});
|
||||
''');
|
||||
if (settingsStore.showOverlay) {
|
||||
|
|
@ -393,35 +645,104 @@ abstract class VideoStoreBase with Store {
|
|||
/// Called whenever the video/overlay is tapped.
|
||||
@action
|
||||
void handleVideoTap() {
|
||||
_overlayTimer.cancel();
|
||||
if (_isInPipMode) {
|
||||
_overlayVisible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_overlayTimer?.cancel();
|
||||
|
||||
if (_overlayVisible) {
|
||||
_overlayVisible = false;
|
||||
} else {
|
||||
updateStreamInfo();
|
||||
updateStreamInfo(forceUpdate: true);
|
||||
|
||||
_overlayVisible = true;
|
||||
_overlayTimer =
|
||||
Timer(const Duration(seconds: 5), () => _overlayVisible = false);
|
||||
_scheduleOverlayHide();
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the periodic stream info timer for chat-only mode.
|
||||
void _startStreamInfoTimer() {
|
||||
// Only start if not already active
|
||||
if (_streamInfoTimer?.isActive != true) {
|
||||
_streamInfoTimer = Timer.periodic(
|
||||
const Duration(seconds: 60),
|
||||
(_) => updateStreamInfo(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops the periodic stream info timer.
|
||||
void _stopStreamInfoTimer() {
|
||||
if (_streamInfoTimer?.isActive == true) {
|
||||
_streamInfoTimer?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleOverlayHide([Duration delay = const Duration(seconds: 5)]) {
|
||||
_overlayTimer?.cancel();
|
||||
|
||||
if (_isInPipMode) {
|
||||
_overlayVisible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_overlayTimer = Timer(delay, () {
|
||||
if (_isInPipMode) return;
|
||||
|
||||
runInAction(() {
|
||||
_overlayVisible = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Handles app resume event for immediate stream info refresh in chat-only mode.
|
||||
@action
|
||||
void handleAppResume() {
|
||||
// Only refresh immediately in chat-only mode
|
||||
if (!settingsStore.showVideo) {
|
||||
updateStreamInfo(forceUpdate: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the stream info from the Twitch API.
|
||||
///
|
||||
/// If the stream is offline, disables the overlay.
|
||||
/// If the stream is offline, fetches channel information to show offline details.
|
||||
/// Set [forceUpdate] to true to bypass the rate limiting check.
|
||||
@action
|
||||
Future<void> updateStreamInfo() async {
|
||||
try {
|
||||
_streamInfo = await twitchApi.getStream(
|
||||
userLogin: userLogin,
|
||||
headers: authStore.headersTwitch,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint(e.toString());
|
||||
Future<void> updateStreamInfo({bool forceUpdate = false}) async {
|
||||
// Rate limiting: prevent too frequent updates unless forced
|
||||
final now = DateTime.now();
|
||||
if (!forceUpdate && _lastStreamInfoUpdate != null) {
|
||||
final timeSinceLastUpdate = now.difference(_lastStreamInfoUpdate!);
|
||||
if (timeSinceLastUpdate.inSeconds < 5) {
|
||||
return; // Skip update if less than 5 seconds since last update
|
||||
}
|
||||
}
|
||||
|
||||
_overlayTimer.cancel();
|
||||
_lastStreamInfoUpdate = now;
|
||||
|
||||
try {
|
||||
_streamInfo = await twitchApi.getStream(userLogin: userLogin);
|
||||
// Clear offline info when stream is live
|
||||
_offlineChannelInfo = null;
|
||||
} catch (e) {
|
||||
_overlayTimer?.cancel();
|
||||
_streamInfo = null;
|
||||
_paused = true;
|
||||
|
||||
// Try to fetch offline channel information
|
||||
try {
|
||||
_offlineChannelInfo = await twitchApi.getChannel(userId: userId);
|
||||
} catch (channelError) {
|
||||
_offlineChannelInfo = null;
|
||||
}
|
||||
|
||||
// Restart overlay timer in chat-only mode even on error
|
||||
if (!settingsStore.showVideo) {
|
||||
_scheduleOverlayHide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -437,21 +758,30 @@ abstract class VideoStoreBase with Store {
|
|||
|
||||
if (settingsStore.showOverlay) {
|
||||
_overlayVisible = true;
|
||||
_scheduleOverlayHide(const Duration(seconds: 3));
|
||||
}
|
||||
}
|
||||
|
||||
_overlayTimer.cancel();
|
||||
_overlayTimer =
|
||||
Timer(const Duration(seconds: 3), () => _overlayVisible = false);
|
||||
}
|
||||
}
|
||||
// Stream info timer is managed automatically by the video mode reaction
|
||||
}
|
||||
|
||||
/// Refreshes the stream webview and updates the stream info.
|
||||
///
|
||||
/// Performs a hard refresh by loading about:blank first to completely clear
|
||||
/// the webview state, then immediately reloads the video URL. This is more
|
||||
/// reliable than a soft reload when the webview is sluggish.
|
||||
@action
|
||||
void handleRefresh() {
|
||||
Future<void> handleRefresh() async {
|
||||
HapticFeedback.lightImpact();
|
||||
_paused = true;
|
||||
_firstTimeSettingQuality = true;
|
||||
videoWebViewController.reload();
|
||||
_isInPipMode = false;
|
||||
|
||||
// Hard refresh: clear everything by loading blank page first
|
||||
await videoWebViewController.loadRequest(Uri.parse('about:blank'));
|
||||
// Then immediately load the video URL for a fresh start
|
||||
await videoWebViewController.loadRequest(Uri.parse(videoUrl));
|
||||
|
||||
updateStreamInfo();
|
||||
}
|
||||
|
||||
|
|
@ -459,8 +789,9 @@ abstract class VideoStoreBase with Store {
|
|||
void handlePausePlay() {
|
||||
try {
|
||||
if (_paused) {
|
||||
videoWebViewController
|
||||
.runJavaScript('document.getElementsByTagName("video")[0].play();');
|
||||
videoWebViewController.runJavaScript(
|
||||
'document.getElementsByTagName("video")[0].play();',
|
||||
);
|
||||
} else {
|
||||
videoWebViewController.runJavaScript(
|
||||
'document.getElementsByTagName("video")[0].pause();',
|
||||
|
|
@ -489,6 +820,32 @@ abstract class VideoStoreBase with Store {
|
|||
}
|
||||
}
|
||||
|
||||
/// Toggle picture-in-picture mode.
|
||||
///
|
||||
/// If not in PiP mode, enters PiP mode.
|
||||
/// If already in PiP mode on iOS, exits PiP mode.
|
||||
/// On Android, always enters PiP mode (no programmatic exit or state tracking).
|
||||
@action
|
||||
void togglePictureInPicture() {
|
||||
try {
|
||||
if (Platform.isIOS && _isInPipMode) {
|
||||
// Exit PiP mode on iOS
|
||||
videoWebViewController.runJavaScript('''
|
||||
(function() {
|
||||
if (document.pictureInPictureElement) {
|
||||
document.exitPictureInPicture();
|
||||
}
|
||||
})();
|
||||
''');
|
||||
} else {
|
||||
// Enter PiP mode (both iOS and Android)
|
||||
requestPictureInPicture();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
void dispose() {
|
||||
// Disable auto PiP when leaving so that we don't enter PiP on other screens.
|
||||
|
|
@ -498,9 +855,20 @@ abstract class VideoStoreBase with Store {
|
|||
});
|
||||
}
|
||||
|
||||
_overlayTimer.cancel();
|
||||
_overlayTimer?.cancel();
|
||||
_streamInfoTimer?.cancel();
|
||||
|
||||
_disposeOverlayReaction();
|
||||
_disposeVideoModeReaction();
|
||||
_disposeAndroidAutoPipReaction?.call();
|
||||
|
||||
// Note: JavaScript cleanup and channel removal are unnecessary here.
|
||||
// The Video widget loads about:blank during disposal (video.dart:63-65),
|
||||
// which automatically clears all JavaScript state, including:
|
||||
// - Event listeners (play/pause/pip)
|
||||
// - Latency tracker intermittent cycling
|
||||
// - MutationObservers (_hideOverlayObserver)
|
||||
// - JavaScript channels
|
||||
// Attempting cleanup here causes a race condition crash.
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@ part of 'video_store.dart';
|
|||
// 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 _$VideoStore on VideoStoreBase, Store {
|
||||
late final _$_pausedAtom =
|
||||
Atom(name: 'VideoStoreBase._paused', context: context);
|
||||
late final _$_pausedAtom = Atom(
|
||||
name: 'VideoStoreBase._paused',
|
||||
context: context,
|
||||
);
|
||||
|
||||
bool get paused {
|
||||
_$_pausedAtom.reportRead();
|
||||
|
|
@ -27,8 +29,10 @@ mixin _$VideoStore on VideoStoreBase, Store {
|
|||
});
|
||||
}
|
||||
|
||||
late final _$_overlayVisibleAtom =
|
||||
Atom(name: 'VideoStoreBase._overlayVisible', context: context);
|
||||
late final _$_overlayVisibleAtom = Atom(
|
||||
name: 'VideoStoreBase._overlayVisible',
|
||||
context: context,
|
||||
);
|
||||
|
||||
bool get overlayVisible {
|
||||
_$_overlayVisibleAtom.reportRead();
|
||||
|
|
@ -45,8 +49,10 @@ mixin _$VideoStore on VideoStoreBase, Store {
|
|||
});
|
||||
}
|
||||
|
||||
late final _$_streamInfoAtom =
|
||||
Atom(name: 'VideoStoreBase._streamInfo', context: context);
|
||||
late final _$_streamInfoAtom = Atom(
|
||||
name: 'VideoStoreBase._streamInfo',
|
||||
context: context,
|
||||
);
|
||||
|
||||
StreamTwitch? get streamInfo {
|
||||
_$_streamInfoAtom.reportRead();
|
||||
|
|
@ -63,8 +69,30 @@ mixin _$VideoStore on VideoStoreBase, Store {
|
|||
});
|
||||
}
|
||||
|
||||
late final _$_availableStreamQualitiesAtom =
|
||||
Atom(name: 'VideoStoreBase._availableStreamQualities', context: context);
|
||||
late final _$_offlineChannelInfoAtom = Atom(
|
||||
name: 'VideoStoreBase._offlineChannelInfo',
|
||||
context: context,
|
||||
);
|
||||
|
||||
Channel? get offlineChannelInfo {
|
||||
_$_offlineChannelInfoAtom.reportRead();
|
||||
return super._offlineChannelInfo;
|
||||
}
|
||||
|
||||
@override
|
||||
Channel? get _offlineChannelInfo => offlineChannelInfo;
|
||||
|
||||
@override
|
||||
set _offlineChannelInfo(Channel? value) {
|
||||
_$_offlineChannelInfoAtom.reportWrite(value, super._offlineChannelInfo, () {
|
||||
super._offlineChannelInfo = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$_availableStreamQualitiesAtom = Atom(
|
||||
name: 'VideoStoreBase._availableStreamQualities',
|
||||
context: context,
|
||||
);
|
||||
|
||||
List<String> get availableStreamQualities {
|
||||
_$_availableStreamQualitiesAtom.reportRead();
|
||||
|
|
@ -76,14 +104,19 @@ mixin _$VideoStore on VideoStoreBase, Store {
|
|||
|
||||
@override
|
||||
set _availableStreamQualities(List<String> value) {
|
||||
_$_availableStreamQualitiesAtom
|
||||
.reportWrite(value, super._availableStreamQualities, () {
|
||||
_$_availableStreamQualitiesAtom.reportWrite(
|
||||
value,
|
||||
super._availableStreamQualities,
|
||||
() {
|
||||
super._availableStreamQualities = value;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
late final _$_streamQualityIndexAtom =
|
||||
Atom(name: 'VideoStoreBase._streamQualityIndex', context: context);
|
||||
late final _$_streamQualityIndexAtom = Atom(
|
||||
name: 'VideoStoreBase._streamQualityIndex',
|
||||
context: context,
|
||||
);
|
||||
|
||||
int get streamQualityIndex {
|
||||
_$_streamQualityIndexAtom.reportRead();
|
||||
|
|
@ -100,8 +133,10 @@ mixin _$VideoStore on VideoStoreBase, Store {
|
|||
});
|
||||
}
|
||||
|
||||
late final _$_latencyAtom =
|
||||
Atom(name: 'VideoStoreBase._latency', context: context);
|
||||
late final _$_latencyAtom = Atom(
|
||||
name: 'VideoStoreBase._latency',
|
||||
context: context,
|
||||
);
|
||||
|
||||
String? get latency {
|
||||
_$_latencyAtom.reportRead();
|
||||
|
|
@ -118,56 +153,104 @@ mixin _$VideoStore on VideoStoreBase, Store {
|
|||
});
|
||||
}
|
||||
|
||||
late final _$updateStreamQualitiesAsyncAction =
|
||||
AsyncAction('VideoStoreBase.updateStreamQualities', context: context);
|
||||
late final _$_isInPipModeAtom = Atom(
|
||||
name: 'VideoStoreBase._isInPipMode',
|
||||
context: context,
|
||||
);
|
||||
|
||||
bool get isInPipMode {
|
||||
_$_isInPipModeAtom.reportRead();
|
||||
return super._isInPipMode;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get _isInPipMode => isInPipMode;
|
||||
|
||||
@override
|
||||
set _isInPipMode(bool value) {
|
||||
_$_isInPipModeAtom.reportWrite(value, super._isInPipMode, () {
|
||||
super._isInPipMode = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$updateStreamQualitiesAsyncAction = AsyncAction(
|
||||
'VideoStoreBase.updateStreamQualities',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> updateStreamQualities() {
|
||||
return _$updateStreamQualitiesAsyncAction
|
||||
.run(() => super.updateStreamQualities());
|
||||
return _$updateStreamQualitiesAsyncAction.run(
|
||||
() => super.updateStreamQualities(),
|
||||
);
|
||||
}
|
||||
|
||||
late final _$setStreamQualityAsyncAction =
|
||||
AsyncAction('VideoStoreBase.setStreamQuality', context: context);
|
||||
late final _$setStreamQualityAsyncAction = AsyncAction(
|
||||
'VideoStoreBase.setStreamQuality',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> setStreamQuality(String newStreamQuality) {
|
||||
return _$setStreamQualityAsyncAction
|
||||
.run(() => super.setStreamQuality(newStreamQuality));
|
||||
return _$setStreamQualityAsyncAction.run(
|
||||
() => super.setStreamQuality(newStreamQuality),
|
||||
);
|
||||
}
|
||||
|
||||
late final _$_setStreamQualityIndexAsyncAction =
|
||||
AsyncAction('VideoStoreBase._setStreamQualityIndex', context: context);
|
||||
late final _$_setStreamQualityIndexAsyncAction = AsyncAction(
|
||||
'VideoStoreBase._setStreamQualityIndex',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> _setStreamQualityIndex(int newStreamQualityIndex) {
|
||||
return _$_setStreamQualityIndexAsyncAction
|
||||
.run(() => super._setStreamQualityIndex(newStreamQualityIndex));
|
||||
return _$_setStreamQualityIndexAsyncAction.run(
|
||||
() => super._setStreamQualityIndex(newStreamQualityIndex),
|
||||
);
|
||||
}
|
||||
|
||||
late final _$initVideoAsyncAction =
|
||||
AsyncAction('VideoStoreBase.initVideo', context: context);
|
||||
late final _$initVideoAsyncAction = AsyncAction(
|
||||
'VideoStoreBase.initVideo',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> initVideo() {
|
||||
return _$initVideoAsyncAction.run(() => super.initVideo());
|
||||
}
|
||||
|
||||
late final _$updateStreamInfoAsyncAction =
|
||||
AsyncAction('VideoStoreBase.updateStreamInfo', context: context);
|
||||
late final _$updateStreamInfoAsyncAction = AsyncAction(
|
||||
'VideoStoreBase.updateStreamInfo',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> updateStreamInfo() {
|
||||
return _$updateStreamInfoAsyncAction.run(() => super.updateStreamInfo());
|
||||
Future<void> updateStreamInfo({bool forceUpdate = false}) {
|
||||
return _$updateStreamInfoAsyncAction.run(
|
||||
() => super.updateStreamInfo(forceUpdate: forceUpdate),
|
||||
);
|
||||
}
|
||||
|
||||
late final _$VideoStoreBaseActionController =
|
||||
ActionController(name: 'VideoStoreBase', context: context);
|
||||
late final _$handleRefreshAsyncAction = AsyncAction(
|
||||
'VideoStoreBase.handleRefresh',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> handleRefresh() {
|
||||
return _$handleRefreshAsyncAction.run(() => super.handleRefresh());
|
||||
}
|
||||
|
||||
late final _$VideoStoreBaseActionController = ActionController(
|
||||
name: 'VideoStoreBase',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
void handleVideoTap() {
|
||||
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
|
||||
name: 'VideoStoreBase.handleVideoTap');
|
||||
name: 'VideoStoreBase.handleVideoTap',
|
||||
);
|
||||
try {
|
||||
return super.handleVideoTap();
|
||||
} finally {
|
||||
|
|
@ -175,10 +258,23 @@ mixin _$VideoStore on VideoStoreBase, Store {
|
|||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void handleAppResume() {
|
||||
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
|
||||
name: 'VideoStoreBase.handleAppResume',
|
||||
);
|
||||
try {
|
||||
return super.handleAppResume();
|
||||
} finally {
|
||||
_$VideoStoreBaseActionController.endAction(_$actionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void handleToggleOverlay() {
|
||||
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
|
||||
name: 'VideoStoreBase.handleToggleOverlay');
|
||||
name: 'VideoStoreBase.handleToggleOverlay',
|
||||
);
|
||||
try {
|
||||
return super.handleToggleOverlay();
|
||||
} finally {
|
||||
|
|
@ -187,11 +283,12 @@ mixin _$VideoStore on VideoStoreBase, Store {
|
|||
}
|
||||
|
||||
@override
|
||||
void handleRefresh() {
|
||||
void togglePictureInPicture() {
|
||||
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
|
||||
name: 'VideoStoreBase.handleRefresh');
|
||||
name: 'VideoStoreBase.togglePictureInPicture',
|
||||
);
|
||||
try {
|
||||
return super.handleRefresh();
|
||||
return super.togglePictureInPicture();
|
||||
} finally {
|
||||
_$VideoStoreBaseActionController.endAction(_$actionInfo);
|
||||
}
|
||||
|
|
@ -200,7 +297,8 @@ mixin _$VideoStore on VideoStoreBase, Store {
|
|||
@override
|
||||
void dispose() {
|
||||
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
|
||||
name: 'VideoStoreBase.dispose');
|
||||
name: 'VideoStoreBase.dispose',
|
||||
);
|
||||
try {
|
||||
return super.dispose();
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import 'package:frosty/screens/settings/settings.dart';
|
|||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
import 'package:frosty/screens/settings/widgets/release_notes.dart';
|
||||
import 'package:frosty/widgets/blurred_container.dart';
|
||||
import 'package:frosty/widgets/profile_picture.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
|
@ -59,13 +60,41 @@ class _HomeState extends State<Home> {
|
|||
SystemChrome.setPreferredOrientations([]);
|
||||
|
||||
final isLoggedIn = _authStore.isLoggedIn && _authStore.user.details != null;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: FocusScope.of(context).unfocus,
|
||||
child: Scaffold(
|
||||
backgroundColor: theme.scaffoldBackgroundColor,
|
||||
extendBody: true,
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: AppBar(
|
||||
centerTitle: false,
|
||||
shape: const Border(),
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.transparent,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
titleSpacing: 16,
|
||||
systemOverlayStyle: SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: theme.brightness == Brightness.dark
|
||||
? Brightness.light
|
||||
: Brightness.dark,
|
||||
),
|
||||
flexibleSpace: Observer(
|
||||
builder: (_) {
|
||||
// Only show flexible space on Following tab
|
||||
final isOnFollowingTab =
|
||||
isLoggedIn && _homeStore.selectedIndex == 0;
|
||||
|
||||
// Only show flexible space when on Following tab
|
||||
if (!isOnFollowingTab) return const SizedBox.shrink();
|
||||
|
||||
return BlurredContainer(
|
||||
gradientDirection: GradientDirection.up,
|
||||
child: const SizedBox.expand(),
|
||||
);
|
||||
},
|
||||
),
|
||||
title: Observer(
|
||||
builder: (_) {
|
||||
final titles = [
|
||||
|
|
@ -99,8 +128,7 @@ class _HomeState extends State<Home> {
|
|||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Observer(
|
||||
body: Observer(
|
||||
builder: (_) => IndexedStack(
|
||||
index: _homeStore.selectedIndex,
|
||||
children: [
|
||||
|
|
@ -109,45 +137,69 @@ class _HomeState extends State<Home> {
|
|||
listType: ListType.followed,
|
||||
scrollController: _homeStore.followedScrollController,
|
||||
),
|
||||
TopSection(
|
||||
homeStore: _homeStore,
|
||||
),
|
||||
Search(
|
||||
scrollController: _homeStore.searchScrollController,
|
||||
),
|
||||
TopSection(homeStore: _homeStore),
|
||||
Search(scrollController: _homeStore.searchScrollController),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Divider(),
|
||||
Observer(
|
||||
builder: (_) => NavigationBar(
|
||||
bottomNavigationBar: BlurredContainer(
|
||||
gradientDirection: GradientDirection.down,
|
||||
child: Observer(
|
||||
builder: (_) => Theme(
|
||||
data: Theme.of(
|
||||
context,
|
||||
).copyWith(splashFactory: NoSplash.splashFactory),
|
||||
child: NavigationBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
destinations: [
|
||||
if (_authStore.isLoggedIn)
|
||||
const NavigationDestination(
|
||||
NavigationDestination(
|
||||
icon: Icon(
|
||||
Icons.favorite_border_rounded,
|
||||
color: _homeStore.selectedIndex == 0
|
||||
? theme.colorScheme.onSurface
|
||||
: theme.colorScheme.onSurfaceVariant.withValues(
|
||||
alpha: 0.6,
|
||||
),
|
||||
),
|
||||
selectedIcon: Icon(
|
||||
Icons.favorite_rounded,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
selectedIcon: Icon(Icons.favorite_rounded),
|
||||
label: 'Following',
|
||||
tooltip: 'Following',
|
||||
),
|
||||
const NavigationDestination(
|
||||
NavigationDestination(
|
||||
icon: Icon(
|
||||
Icons.arrow_upward_rounded,
|
||||
color: _homeStore.selectedIndex == (isLoggedIn ? 1 : 0)
|
||||
? theme.colorScheme.onSurface
|
||||
: theme.colorScheme.onSurfaceVariant.withValues(
|
||||
alpha: 0.6,
|
||||
),
|
||||
),
|
||||
selectedIcon: Icon(
|
||||
Icons.arrow_upward_rounded,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
selectedIcon: Icon(Icons.arrow_upward_rounded),
|
||||
label: 'Top',
|
||||
tooltip: 'Top',
|
||||
),
|
||||
const NavigationDestination(
|
||||
NavigationDestination(
|
||||
icon: Icon(
|
||||
Icons.search_rounded,
|
||||
color: _homeStore.selectedIndex == (isLoggedIn ? 2 : 1)
|
||||
? theme.colorScheme.onSurface
|
||||
: theme.colorScheme.onSurfaceVariant.withValues(
|
||||
alpha: 0.6,
|
||||
),
|
||||
),
|
||||
selectedIcon: Icon(
|
||||
Icons.search_rounded,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
selectedIcon: Icon(Icons.search_rounded),
|
||||
label: 'Search',
|
||||
tooltip: 'Search',
|
||||
),
|
||||
|
|
@ -156,7 +208,7 @@ class _HomeState extends State<Home> {
|
|||
onDestinationSelected: _homeStore.handleTap,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,15 +16,13 @@ abstract class HomeStoreBase with Store {
|
|||
|
||||
/// The scroll controllers for controlling the scroll to top on the top section.
|
||||
/// One controller is for the top streams tab and the other is for the top categories tab.
|
||||
final topSectionScrollControllers = [
|
||||
ScrollController(),
|
||||
ScrollController(),
|
||||
];
|
||||
final topSectionScrollControllers = [ScrollController(), ScrollController()];
|
||||
|
||||
/// The scroll controller for controlling the scroll to top on the search section.
|
||||
final searchScrollController = ScrollController();
|
||||
|
||||
/// The current index of the top section tab. Changes when switching between the streams and categories tabs.
|
||||
@observable
|
||||
var topSectionCurrentIndex = 0;
|
||||
|
||||
/// The current selected index/tab of the bottom navigation bar.
|
||||
|
|
@ -99,6 +97,7 @@ abstract class HomeStoreBase with Store {
|
|||
_disposeReaction();
|
||||
|
||||
followedScrollController.dispose();
|
||||
searchScrollController.dispose();
|
||||
|
||||
for (final controller in topSectionScrollControllers) {
|
||||
controller.dispose();
|
||||
|
|
|
|||
|
|
@ -9,8 +9,32 @@ part of 'home_store.dart';
|
|||
// 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 _$HomeStore on HomeStoreBase, Store {
|
||||
late final _$_selectedIndexAtom =
|
||||
Atom(name: 'HomeStoreBase._selectedIndex', context: context);
|
||||
late final _$topSectionCurrentIndexAtom = Atom(
|
||||
name: 'HomeStoreBase.topSectionCurrentIndex',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
int get topSectionCurrentIndex {
|
||||
_$topSectionCurrentIndexAtom.reportRead();
|
||||
return super.topSectionCurrentIndex;
|
||||
}
|
||||
|
||||
@override
|
||||
set topSectionCurrentIndex(int value) {
|
||||
_$topSectionCurrentIndexAtom.reportWrite(
|
||||
value,
|
||||
super.topSectionCurrentIndex,
|
||||
() {
|
||||
super.topSectionCurrentIndex = value;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
late final _$_selectedIndexAtom = Atom(
|
||||
name: 'HomeStoreBase._selectedIndex',
|
||||
context: context,
|
||||
);
|
||||
|
||||
int get selectedIndex {
|
||||
_$_selectedIndexAtom.reportRead();
|
||||
|
|
@ -27,13 +51,16 @@ mixin _$HomeStore on HomeStoreBase, Store {
|
|||
});
|
||||
}
|
||||
|
||||
late final _$HomeStoreBaseActionController =
|
||||
ActionController(name: 'HomeStoreBase', context: context);
|
||||
late final _$HomeStoreBaseActionController = ActionController(
|
||||
name: 'HomeStoreBase',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
void handleTap(int index) {
|
||||
final _$actionInfo = _$HomeStoreBaseActionController.startAction(
|
||||
name: 'HomeStoreBase.handleTap');
|
||||
name: 'HomeStoreBase.handleTap',
|
||||
);
|
||||
try {
|
||||
return super.handleTap(index);
|
||||
} finally {
|
||||
|
|
@ -44,7 +71,7 @@ mixin _$HomeStore on HomeStoreBase, Store {
|
|||
@override
|
||||
String toString() {
|
||||
return '''
|
||||
|
||||
topSectionCurrentIndex: ${topSectionCurrentIndex}
|
||||
''';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ import 'package:frosty/apis/twitch_api.dart';
|
|||
import 'package:frosty/screens/home/search/search_results_categories.dart';
|
||||
import 'package:frosty/screens/home/search/search_results_channels.dart';
|
||||
import 'package:frosty/screens/home/search/search_store.dart';
|
||||
import 'package:frosty/screens/home/stream_list/streams_list.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/widgets/alert_message.dart';
|
||||
import 'package:frosty/widgets/animated_scroll_border.dart';
|
||||
import 'package:frosty/widgets/blurred_container.dart';
|
||||
import 'package:frosty/widgets/frosty_scrollbar.dart';
|
||||
import 'package:frosty/widgets/section_header.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
|
|
@ -16,15 +18,15 @@ class Search extends StatefulWidget {
|
|||
// The scroll controller for handling scroll to top functionality.
|
||||
final ScrollController scrollController;
|
||||
|
||||
const Search({
|
||||
super.key,
|
||||
required this.scrollController,
|
||||
});
|
||||
const Search({super.key, required this.scrollController});
|
||||
|
||||
@override
|
||||
State<Search> createState() => _SearchState();
|
||||
}
|
||||
|
||||
// Constants for consistent sizing
|
||||
const double _kSearchBarHeight = 80.0;
|
||||
|
||||
class _SearchState extends State<Search> {
|
||||
late final _searchStore = SearchStore(
|
||||
authStore: context.read<AuthStore>(),
|
||||
|
|
@ -33,56 +35,61 @@ class _SearchState extends State<Search> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
return Stack(
|
||||
children: [
|
||||
Observer(
|
||||
builder: (context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TextField(
|
||||
controller: _searchStore.textEditingController,
|
||||
focusNode: _searchStore.textFieldFocusNode,
|
||||
autocorrect: false,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.search_rounded),
|
||||
hintText: 'Find a channel or category',
|
||||
suffixIcon: _searchStore.textFieldFocusNode.hasFocus ||
|
||||
_searchStore.searchText.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
tooltip: _searchStore.searchText.isEmpty
|
||||
? 'Cancel'
|
||||
: 'Clear',
|
||||
onPressed: () {
|
||||
if (_searchStore.searchText.isEmpty) {
|
||||
_searchStore.textFieldFocusNode.unfocus();
|
||||
}
|
||||
_searchStore.textEditingController.clear();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onSubmitted: _searchStore.handleQuery,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
AnimatedScrollBorder(scrollController: widget.scrollController),
|
||||
Expanded(
|
||||
child: Scrollbar(
|
||||
controller: widget.scrollController,
|
||||
// Content behind the search bar
|
||||
Positioned.fill(
|
||||
child: Observer(
|
||||
builder: (context) {
|
||||
if (_searchStore.textEditingController.text.isEmpty) {
|
||||
if (_searchStore.searchHistory.isEmpty) {
|
||||
return const AlertMessage(message: 'No recent searches');
|
||||
// Keep controller attached so borders stay in sync
|
||||
return CustomScrollView(
|
||||
controller: widget.scrollController,
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height:
|
||||
MediaQuery.of(context).padding.top +
|
||||
_kSearchBarHeight,
|
||||
),
|
||||
),
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: const AlertMessage(
|
||||
message: 'No recent searches',
|
||||
vertical: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
return FrostyScrollbar(
|
||||
controller: widget.scrollController,
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top + _kSearchBarHeight,
|
||||
bottom: MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: ListView(
|
||||
controller: widget.scrollController,
|
||||
padding: EdgeInsets.only(
|
||||
top:
|
||||
MediaQuery.of(context).padding.top +
|
||||
_kSearchBarHeight,
|
||||
bottom: MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 4),
|
||||
padding: EdgeInsets.only(
|
||||
left: 16 + MediaQuery.of(context).padding.left,
|
||||
right: 4 + MediaQuery.of(context).padding.right,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
|
|
@ -98,11 +105,7 @@ class _SearchState extends State<Search> {
|
|||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
controller: widget.scrollController,
|
||||
children: _searchStore.searchHistory
|
||||
.mapIndexed(
|
||||
..._searchStore.searchHistory.mapIndexed(
|
||||
(index, searchTerm) => ListTile(
|
||||
leading: const Icon(Icons.history_rounded),
|
||||
title: Text(searchTerm),
|
||||
|
|
@ -110,42 +113,147 @@ class _SearchState extends State<Search> {
|
|||
_searchStore.textEditingController.text =
|
||||
searchTerm;
|
||||
_searchStore.handleQuery(searchTerm);
|
||||
_searchStore.textEditingController
|
||||
.selection = TextSelection.fromPosition(
|
||||
_searchStore.textEditingController.selection =
|
||||
TextSelection.fromPosition(
|
||||
TextPosition(
|
||||
offset: _searchStore
|
||||
.textEditingController.text.length,
|
||||
.textEditingController
|
||||
.text
|
||||
.length,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return CustomScrollView(
|
||||
return FrostyScrollbar(
|
||||
controller: widget.scrollController,
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top + _kSearchBarHeight,
|
||||
bottom: MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: CustomScrollView(
|
||||
controller: widget.scrollController,
|
||||
slivers: [
|
||||
const SliverToBoxAdapter(
|
||||
child: SectionHeader(
|
||||
// Add padding for app bar and search bar
|
||||
_SearchTopPadding(),
|
||||
SliverToBoxAdapter(
|
||||
child: Builder(
|
||||
builder: (context) => SectionHeader(
|
||||
'Channels',
|
||||
isFirst: true,
|
||||
padding: EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16 + MediaQuery.of(context).padding.left,
|
||||
12,
|
||||
16 + MediaQuery.of(context).padding.right,
|
||||
8,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SearchResultsChannels(
|
||||
searchStore: _searchStore,
|
||||
query: _searchStore.searchText,
|
||||
),
|
||||
const SliverToBoxAdapter(
|
||||
child: SectionHeader(
|
||||
SliverToBoxAdapter(
|
||||
child: Builder(
|
||||
builder: (context) => SectionHeader(
|
||||
'Categories',
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16 + MediaQuery.of(context).padding.left,
|
||||
8,
|
||||
16 + MediaQuery.of(context).padding.right,
|
||||
8,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SearchResultsCategories(searchStore: _searchStore),
|
||||
// Add padding for bottom navigation bar
|
||||
const SliverBottomPadding(),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// Blurred search bar on top
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: BlurredContainer(
|
||||
gradientDirection: GradientDirection.up,
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top,
|
||||
left: MediaQuery.of(context).padding.left,
|
||||
right: MediaQuery.of(context).padding.right,
|
||||
),
|
||||
child: Observer(
|
||||
builder: (context) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TextField(
|
||||
controller: _searchStore.textEditingController,
|
||||
focusNode: _searchStore.textFieldFocusNode,
|
||||
autocorrect: false,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.search_rounded),
|
||||
hintText: 'Search channels or categories',
|
||||
suffixIcon:
|
||||
_searchStore.textFieldFocusNode.hasFocus ||
|
||||
_searchStore.searchText.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
tooltip: _searchStore.searchText.isEmpty
|
||||
? 'Cancel'
|
||||
: 'Clear',
|
||||
onPressed: () {
|
||||
if (_searchStore.searchText.isEmpty) {
|
||||
_searchStore.textFieldFocusNode.unfocus();
|
||||
}
|
||||
_searchStore.textEditingController.clear();
|
||||
// Ensure borders reset: scroll back to top and trigger listeners
|
||||
if (widget.scrollController.hasClients) {
|
||||
widget.scrollController.animateTo(
|
||||
0,
|
||||
duration: const Duration(
|
||||
milliseconds: 150,
|
||||
),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
// After the frame (when content swaps), re-evaluate border state
|
||||
WidgetsBinding.instance.addPostFrameCallback((
|
||||
_,
|
||||
) {
|
||||
if (widget.scrollController.hasClients) {
|
||||
// Nudge listeners even if already at zero
|
||||
final offset =
|
||||
widget.scrollController.offset;
|
||||
final target = (offset == 0)
|
||||
? 0.01
|
||||
: 0.0;
|
||||
widget.scrollController.jumpTo(
|
||||
(offset == 0) ? target : 0.0,
|
||||
);
|
||||
if (target == 0.01) {
|
||||
widget.scrollController.jumpTo(0.0);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onSubmitted: _searchStore.handleQuery,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
|
@ -162,3 +270,17 @@ class _SearchState extends State<Search> {
|
|||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper widget for consistent top padding in search slivers
|
||||
class _SearchTopPadding extends StatelessWidget {
|
||||
const _SearchTopPadding();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).padding.top + _kSearchBarHeight,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import 'package:frosty/models/category.dart';
|
|||
import 'package:frosty/screens/home/search/search_store.dart';
|
||||
import 'package:frosty/screens/home/top/categories/category_card.dart';
|
||||
import 'package:frosty/widgets/alert_message.dart';
|
||||
import 'package:frosty/widgets/loading_indicator.dart';
|
||||
import 'package:frosty/widgets/skeleton_loader.dart';
|
||||
import 'package:mobx/mobx.dart';
|
||||
|
||||
class SearchResultsCategories extends StatelessWidget {
|
||||
|
|
@ -18,21 +18,24 @@ class SearchResultsCategories extends StatelessWidget {
|
|||
builder: (context) {
|
||||
final future = searchStore.categoryFuture;
|
||||
|
||||
switch (future!.status) {
|
||||
if (future == null) {
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
switch (future.status) {
|
||||
case FutureStatus.pending:
|
||||
return const SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 100.0,
|
||||
child: LoadingIndicator(
|
||||
subtitle: 'Loading categories...',
|
||||
),
|
||||
),
|
||||
return SliverList.builder(
|
||||
itemCount: 8,
|
||||
itemBuilder: (context, index) => const CategorySkeletonLoader(),
|
||||
);
|
||||
case FutureStatus.rejected:
|
||||
return const SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 100.0,
|
||||
child: AlertMessage(message: 'Failed to get categories'),
|
||||
child: AlertMessage(
|
||||
message: 'Unable to load categories',
|
||||
vertical: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
case FutureStatus.fulfilled:
|
||||
|
|
@ -42,7 +45,10 @@ class SearchResultsCategories extends StatelessWidget {
|
|||
return const SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 100.0,
|
||||
child: AlertMessage(message: 'Failed to get categories'),
|
||||
child: AlertMessage(
|
||||
message: 'Failed to get categories',
|
||||
vertical: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -51,16 +57,18 @@ class SearchResultsCategories extends StatelessWidget {
|
|||
return const SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 100.0,
|
||||
child: AlertMessage(message: 'No matching categories'),
|
||||
child: AlertMessage(
|
||||
message: 'No matching categories',
|
||||
vertical: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SliverList.builder(
|
||||
itemCount: categories.data.length,
|
||||
itemBuilder: (context, index) => CategoryCard(
|
||||
category: categories.data[index],
|
||||
),
|
||||
itemBuilder: (context, index) =>
|
||||
CategoryCard(category: categories.data[index]),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
import 'package:frosty/apis/base_api_client.dart';
|
||||
import 'package:frosty/models/channel.dart';
|
||||
import 'package:frosty/screens/channel/channel.dart';
|
||||
import 'package:frosty/screens/home/search/search_store.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/loading_indicator.dart';
|
||||
import 'package:frosty/widgets/live_indicator.dart';
|
||||
import 'package:frosty/widgets/profile_picture.dart';
|
||||
import 'package:frosty/widgets/skeleton_loader.dart';
|
||||
import 'package:frosty/widgets/uptime.dart';
|
||||
import 'package:frosty/widgets/user_actions_modal.dart';
|
||||
import 'package:mobx/mobx.dart';
|
||||
|
|
@ -43,10 +46,18 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
|
|||
),
|
||||
),
|
||||
);
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('Search channels ApiException: $e');
|
||||
final snackBar = SnackBar(
|
||||
content: AlertMessage(message: e.message, centered: false),
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(snackBar);
|
||||
} catch (error) {
|
||||
debugPrint('Search channels error: $error');
|
||||
final snackBar = SnackBar(
|
||||
content: AlertMessage(
|
||||
message: error.toString(),
|
||||
message: 'Unable to follow channel',
|
||||
centered: false,
|
||||
),
|
||||
);
|
||||
|
|
@ -61,18 +72,25 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
|
|||
builder: (context) {
|
||||
final future = widget.searchStore.channelFuture;
|
||||
|
||||
switch (future!.status) {
|
||||
if (future == null) {
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
switch (future.status) {
|
||||
case FutureStatus.pending:
|
||||
return const SliverToBoxAdapter(
|
||||
child: LoadingIndicator(
|
||||
subtitle: 'Loading channels...',
|
||||
),
|
||||
return SliverList.builder(
|
||||
itemCount: 8,
|
||||
itemBuilder: (context, index) =>
|
||||
ChannelSkeletonLoader(index: index),
|
||||
);
|
||||
case FutureStatus.rejected:
|
||||
return const SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 100.0,
|
||||
child: AlertMessage(message: 'Failed to get channels'),
|
||||
child: AlertMessage(
|
||||
message: 'Unable to load channels',
|
||||
vertical: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
case FutureStatus.fulfilled:
|
||||
|
|
@ -84,8 +102,7 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
|
|||
|
||||
return SliverList.list(
|
||||
children: [
|
||||
...results.map(
|
||||
(channel) {
|
||||
...results.map((channel) {
|
||||
final displayName = getReadableName(
|
||||
channel.displayName,
|
||||
channel.broadcasterLogin,
|
||||
|
|
@ -105,7 +122,7 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
|
|||
onLongPress: () {
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
showModalBottomSheet(
|
||||
showModalBottomSheetWithProperFocus(
|
||||
context: context,
|
||||
builder: (context) => UserActionsModal(
|
||||
authStore: widget.searchStore.authStore,
|
||||
|
|
@ -123,21 +140,16 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
|
|||
),
|
||||
subtitle: channel.isLive
|
||||
? Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.circle,
|
||||
color: Colors.red,
|
||||
size: 10,
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
const LiveIndicator(),
|
||||
Uptime(startTime: channel.startedAt),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
}),
|
||||
ListTile(
|
||||
title: Text('Go to channel "${widget.query}"'),
|
||||
onTap: () => _handleSearch(context, widget.query),
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@ abstract class SearchStoreBase with Store {
|
|||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// Retrieve the search history from local storage. If it doesn't exist, use an empty list.
|
||||
_searchHistory = prefs.getStringList('search_history')?.asObservable() ??
|
||||
_searchHistory =
|
||||
prefs.getStringList('search_history')?.asObservable() ??
|
||||
ObservableList<String>();
|
||||
|
||||
// Create a reaction that will limit the history to 8 entries and update it to local storage automatically.
|
||||
|
|
@ -51,8 +52,9 @@ abstract class SearchStoreBase with Store {
|
|||
});
|
||||
|
||||
// Add a listener to update the cancel button visibility whenever the text changes.
|
||||
textEditingController
|
||||
.addListener(() => _searchText = textEditingController.text);
|
||||
textEditingController.addListener(
|
||||
() => _searchText = textEditingController.text,
|
||||
);
|
||||
}
|
||||
|
||||
/// Obtain the channels and categories that match the provided [query].
|
||||
|
|
@ -65,28 +67,19 @@ abstract class SearchStoreBase with Store {
|
|||
_searchHistory.insert(0, query);
|
||||
|
||||
// Fetch the matching channels, sort it by live status, and then set it.
|
||||
_channelFuture = twitchApi
|
||||
.searchChannels(
|
||||
query: query,
|
||||
headers: authStore.headersTwitch,
|
||||
)
|
||||
.then(
|
||||
(channels) {
|
||||
_channelFuture = twitchApi.searchChannels(query: query).then((channels) {
|
||||
channels.sort((c1, c2) => c2.isLive ? 1 : -1);
|
||||
return channels;
|
||||
},
|
||||
).asObservable();
|
||||
}).asObservable();
|
||||
|
||||
// Fetch and set the categories that match the query.
|
||||
_categoryFuture = twitchApi
|
||||
.searchCategories(
|
||||
query: query,
|
||||
headers: authStore.headersTwitch,
|
||||
)
|
||||
.then((categories) {
|
||||
_categoryFuture = twitchApi.searchCategories(query: query).then((
|
||||
categories,
|
||||
) {
|
||||
// Move exact matches to the first result
|
||||
final matchingIndex = categories.data
|
||||
.indexWhere((c) => c.name.toLowerCase() == query.toLowerCase());
|
||||
final matchingIndex = categories.data.indexWhere(
|
||||
(c) => c.name.toLowerCase() == query.toLowerCase(),
|
||||
);
|
||||
if (matchingIndex >= 1) {
|
||||
final matchingCategory = categories.data.removeAt(matchingIndex);
|
||||
categories.data.insert(0, matchingCategory);
|
||||
|
|
@ -98,14 +91,8 @@ abstract class SearchStoreBase with Store {
|
|||
/// Find a specific channel provided the [query].
|
||||
/// This is used for channels that may not show up in the search results.
|
||||
Future<Channel> searchChannel(String query) async {
|
||||
final user = await twitchApi.getUser(
|
||||
userLogin: query,
|
||||
headers: authStore.headersTwitch,
|
||||
);
|
||||
return await twitchApi.getChannel(
|
||||
userId: user.id,
|
||||
headers: authStore.headersTwitch,
|
||||
);
|
||||
final user = await twitchApi.getUser(userLogin: query);
|
||||
return await twitchApi.getChannel(userId: user.id);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@ part of 'search_store.dart';
|
|||
// 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 _$SearchStore on SearchStoreBase, Store {
|
||||
late final _$_searchTextAtom =
|
||||
Atom(name: 'SearchStoreBase._searchText', context: context);
|
||||
late final _$_searchTextAtom = Atom(
|
||||
name: 'SearchStoreBase._searchText',
|
||||
context: context,
|
||||
);
|
||||
|
||||
String get searchText {
|
||||
_$_searchTextAtom.reportRead();
|
||||
|
|
@ -27,8 +29,10 @@ mixin _$SearchStore on SearchStoreBase, Store {
|
|||
});
|
||||
}
|
||||
|
||||
late final _$_searchHistoryAtom =
|
||||
Atom(name: 'SearchStoreBase._searchHistory', context: context);
|
||||
late final _$_searchHistoryAtom = Atom(
|
||||
name: 'SearchStoreBase._searchHistory',
|
||||
context: context,
|
||||
);
|
||||
|
||||
ObservableList<String> get searchHistory {
|
||||
_$_searchHistoryAtom.reportRead();
|
||||
|
|
@ -45,8 +49,10 @@ mixin _$SearchStore on SearchStoreBase, Store {
|
|||
});
|
||||
}
|
||||
|
||||
late final _$_channelFutureAtom =
|
||||
Atom(name: 'SearchStoreBase._channelFuture', context: context);
|
||||
late final _$_channelFutureAtom = Atom(
|
||||
name: 'SearchStoreBase._channelFuture',
|
||||
context: context,
|
||||
);
|
||||
|
||||
ObservableFuture<List<ChannelQuery>>? get channelFuture {
|
||||
_$_channelFutureAtom.reportRead();
|
||||
|
|
@ -63,8 +69,10 @@ mixin _$SearchStore on SearchStoreBase, Store {
|
|||
});
|
||||
}
|
||||
|
||||
late final _$_categoryFutureAtom =
|
||||
Atom(name: 'SearchStoreBase._categoryFuture', context: context);
|
||||
late final _$_categoryFutureAtom = Atom(
|
||||
name: 'SearchStoreBase._categoryFuture',
|
||||
context: context,
|
||||
);
|
||||
|
||||
ObservableFuture<CategoriesTwitch?>? get categoryFuture {
|
||||
_$_categoryFutureAtom.reportRead();
|
||||
|
|
@ -81,21 +89,26 @@ mixin _$SearchStore on SearchStoreBase, Store {
|
|||
});
|
||||
}
|
||||
|
||||
late final _$initAsyncAction =
|
||||
AsyncAction('SearchStoreBase.init', context: context);
|
||||
late final _$initAsyncAction = AsyncAction(
|
||||
'SearchStoreBase.init',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> init() {
|
||||
return _$initAsyncAction.run(() => super.init());
|
||||
}
|
||||
|
||||
late final _$SearchStoreBaseActionController =
|
||||
ActionController(name: 'SearchStoreBase', context: context);
|
||||
late final _$SearchStoreBaseActionController = ActionController(
|
||||
name: 'SearchStoreBase',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
void handleQuery(String query) {
|
||||
final _$actionInfo = _$SearchStoreBaseActionController.startAction(
|
||||
name: 'SearchStoreBase.handleQuery');
|
||||
name: 'SearchStoreBase.handleQuery',
|
||||
);
|
||||
try {
|
||||
return super.handleQuery(query);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -4,15 +4,13 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:frosty/models/stream.dart';
|
||||
import 'package:frosty/screens/channel/channel.dart';
|
||||
import 'package:frosty/screens/channel/video/video_bar.dart';
|
||||
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/theme.dart';
|
||||
import 'package:frosty/utils.dart';
|
||||
import 'package:frosty/widgets/cached_image.dart';
|
||||
import 'package:frosty/widgets/loading_indicator.dart';
|
||||
import 'package:frosty/widgets/uptime.dart';
|
||||
import 'package:frosty/utils/modal_bottom_sheet.dart';
|
||||
import 'package:frosty/widgets/frosty_cached_network_image.dart';
|
||||
import 'package:frosty/widgets/skeleton_loader.dart';
|
||||
import 'package:frosty/widgets/user_actions_modal.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class LargeStreamCard extends StatelessWidget {
|
||||
|
|
@ -45,26 +43,11 @@ class LargeStreamCard extends StatelessWidget {
|
|||
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||
final thumbnailWidth = min((size.width * pixelRatio) ~/ 1, 1920);
|
||||
final thumbnailHeight = min((thumbnailWidth * (9 / 16)).toInt(), 1080);
|
||||
final surfaceColor =
|
||||
context.watch<FrostyThemes>().dark.colorScheme.onSurface;
|
||||
|
||||
final thumbnail = ClipRRect(
|
||||
final thumbnail = SizedBox(
|
||||
width: double.infinity,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: Stack(
|
||||
alignment: Alignment.bottomLeft,
|
||||
children: [
|
||||
Container(
|
||||
foregroundDecoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: [0.8, 1.0],
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: FrostyCachedNetworkImage(
|
||||
|
|
@ -73,70 +56,19 @@ class LargeStreamCard extends StatelessWidget {
|
|||
'-${thumbnailWidth}x$thumbnailHeight',
|
||||
),
|
||||
cacheKey: cacheKey,
|
||||
placeholder: (context, url) => ColoredBox(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: const LoadingIndicator(),
|
||||
placeholder: (context, url) => const SkeletonLoader(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
useOldImageOnUrlChange: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Tooltip(
|
||||
message: 'Stream uptime',
|
||||
preferBelow: false,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.circle,
|
||||
color: Colors.red,
|
||||
size: 10,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Uptime(
|
||||
startTime: streamInfo.startedAt,
|
||||
style: TextStyle(
|
||||
color: surfaceColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Tooltip(
|
||||
message: 'Viewer count',
|
||||
preferBelow: false,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.visibility,
|
||||
size: 14,
|
||||
color: surfaceColor,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
NumberFormat().format(streamInfo.viewerCount),
|
||||
style: TextStyle(
|
||||
color: surfaceColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final streamerName =
|
||||
getReadableName(streamInfo.userName, streamInfo.userLogin);
|
||||
final streamerName = getReadableName(
|
||||
streamInfo.userName,
|
||||
streamInfo.userLogin,
|
||||
);
|
||||
|
||||
return InkWell(
|
||||
onTap: () => Navigator.push(
|
||||
|
|
@ -152,7 +84,7 @@ class LargeStreamCard extends StatelessWidget {
|
|||
onLongPress: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
|
||||
showModalBottomSheet(
|
||||
showModalBottomSheetWithProperFocus(
|
||||
context: context,
|
||||
builder: (context) => UserActionsModal(
|
||||
authStore: context.read<AuthStore>(),
|
||||
|
|
@ -165,17 +97,20 @@ class LargeStreamCard extends StatelessWidget {
|
|||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: showThumbnail ? 12 : 4,
|
||||
horizontal: 16,
|
||||
padding: EdgeInsets.only(
|
||||
top: showThumbnail ? 12 : 4,
|
||||
bottom: showThumbnail ? 12 : 4,
|
||||
left: 16 + MediaQuery.of(context).padding.left,
|
||||
right: 16 + MediaQuery.of(context).padding.right,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
if (showThumbnail) thumbnail,
|
||||
VideoBar(
|
||||
StreamInfoBar(
|
||||
streamInfo: streamInfo,
|
||||
showCategory: showCategory,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
tooltipTriggerMode: TooltipTriggerMode.longPress,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
125
lib/screens/home/stream_list/offline_channel_card.dart
Normal file
125
lib/screens/home/stream_list/offline_channel_card.dart
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:frosty/models/followed_channel.dart';
|
||||
import 'package:frosty/screens/channel/channel.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/utils.dart';
|
||||
import 'package:frosty/utils/modal_bottom_sheet.dart';
|
||||
import 'package:frosty/widgets/profile_picture.dart';
|
||||
import 'package:frosty/widgets/user_actions_modal.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// A tappable card widget that displays an offline followed channel's details.
|
||||
class OfflineChannelCard extends StatelessWidget {
|
||||
final FollowedChannel channelInfo;
|
||||
final bool showPinOption;
|
||||
final bool? isPinned;
|
||||
final bool showOfflineStatus;
|
||||
|
||||
const OfflineChannelCard({
|
||||
super.key,
|
||||
required this.channelInfo,
|
||||
required this.showPinOption,
|
||||
this.isPinned,
|
||||
this.showOfflineStatus = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final channelName = getReadableName(
|
||||
channelInfo.broadcasterName,
|
||||
channelInfo.broadcasterLogin,
|
||||
);
|
||||
|
||||
final fontColor = DefaultTextStyle.of(context).style.color;
|
||||
final followedDuration = DateTime.now().difference(channelInfo.followedAt);
|
||||
|
||||
String followedText;
|
||||
if (followedDuration.inDays >= 365) {
|
||||
final years = followedDuration.inDays ~/ 365;
|
||||
followedText = 'Following for $years ${years == 1 ? 'year' : 'years'}';
|
||||
} else if (followedDuration.inDays >= 30) {
|
||||
final months = followedDuration.inDays ~/ 30;
|
||||
followedText =
|
||||
'Following for $months ${months == 1 ? 'month' : 'months'}';
|
||||
} else if (followedDuration.inDays > 0) {
|
||||
followedText =
|
||||
'Following for ${followedDuration.inDays} ${followedDuration.inDays == 1 ? 'day' : 'days'}';
|
||||
} else if (followedDuration.inHours > 0) {
|
||||
followedText =
|
||||
'Following for ${followedDuration.inHours} ${followedDuration.inHours == 1 ? 'hour' : 'hours'}';
|
||||
} else {
|
||||
followedText =
|
||||
'Following for ${followedDuration.inMinutes} ${followedDuration.inMinutes == 1 ? 'minute' : 'minutes'}';
|
||||
}
|
||||
|
||||
return InkWell(
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => VideoChat(
|
||||
userId: channelInfo.broadcasterId,
|
||||
userName: channelInfo.broadcasterName,
|
||||
userLogin: channelInfo.broadcasterLogin,
|
||||
),
|
||||
),
|
||||
),
|
||||
onLongPress: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
|
||||
showModalBottomSheetWithProperFocus(
|
||||
context: context,
|
||||
builder: (context) => UserActionsModal(
|
||||
authStore: context.read<AuthStore>(),
|
||||
name: channelName,
|
||||
userLogin: channelInfo.broadcasterLogin,
|
||||
userId: channelInfo.broadcasterId,
|
||||
showPinOption: showPinOption,
|
||||
isPinned: isPinned,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16 + MediaQuery.of(context).padding.left,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
// Profile picture
|
||||
ProfilePicture(userLogin: channelInfo.broadcasterLogin),
|
||||
// Channel info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Channel name
|
||||
Text(
|
||||
channelName,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: fontColor,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
// Status and follow duration
|
||||
Text(
|
||||
showOfflineStatus ? 'Offline' : followedText,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: fontColor?.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,10 +8,12 @@ import 'package:frosty/screens/home/top/categories/category_streams.dart';
|
|||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/theme.dart';
|
||||
import 'package:frosty/utils.dart';
|
||||
import 'package:frosty/widgets/cached_image.dart';
|
||||
import 'package:frosty/widgets/loading_indicator.dart';
|
||||
import 'package:frosty/widgets/photo_view.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';
|
||||
import 'package:frosty/widgets/frosty_photo_view_dialog.dart';
|
||||
import 'package:frosty/widgets/profile_picture.dart';
|
||||
import 'package:frosty/widgets/skeleton_loader.dart';
|
||||
import 'package:frosty/widgets/uptime.dart';
|
||||
import 'package:frosty/widgets/user_actions_modal.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
|
@ -57,16 +59,22 @@ class StreamCard extends StatelessWidget {
|
|||
'-${thumbnailWidth}x$thumbnailHeight',
|
||||
),
|
||||
cacheKey: cacheKey,
|
||||
placeholder: (context, url) => ColoredBox(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: const LoadingIndicator(),
|
||||
placeholder: (context, url) => const SkeletonLoader(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
useOldImageOnUrlChange: true,
|
||||
),
|
||||
);
|
||||
|
||||
final streamerName =
|
||||
getReadableName(streamInfo.userName, streamInfo.userLogin);
|
||||
final streamerName = getReadableName(
|
||||
streamInfo.userName,
|
||||
streamInfo.userLogin,
|
||||
);
|
||||
|
||||
final streamTitle = streamInfo.title.trim();
|
||||
final category = streamInfo.gameName.isNotEmpty
|
||||
? streamInfo.gameName
|
||||
: 'No Category';
|
||||
|
||||
const subFontSize = 14.0;
|
||||
|
||||
|
|
@ -81,30 +89,33 @@ class StreamCard extends StatelessWidget {
|
|||
onLongPress: () => showDialog(
|
||||
context: context,
|
||||
builder: (context) => FrostyPhotoViewDialog(
|
||||
imageUrl: streamInfo.thumbnailUrl.replaceFirst(
|
||||
'-{width}x{height}',
|
||||
'',
|
||||
),
|
||||
imageUrl: streamInfo
|
||||
.thumbnailUrl, // Pass original URL with {width}x{height} placeholder
|
||||
cacheKey: cacheKey,
|
||||
),
|
||||
),
|
||||
child: thumbnail,
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color.fromRGBO(0, 0, 0, 0.5),
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(6),
|
||||
),
|
||||
),
|
||||
margin: const EdgeInsets.all(4),
|
||||
margin: const EdgeInsets.all(3),
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(6)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: BlurredContainer(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
sigmaX: 8.0, // Less blur for subtlety
|
||||
sigmaY: 8.0, // Less blur for subtlety
|
||||
forceDarkMode: true,
|
||||
child: Uptime(
|
||||
startTime: streamInfo.startedAt,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: context.watch<FrostyThemes>().dark.colorScheme.onSurface,
|
||||
color: context
|
||||
.watch<FrostyThemes>()
|
||||
.dark
|
||||
.colorScheme
|
||||
.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -116,17 +127,15 @@ class StreamCard extends StatelessWidget {
|
|||
padding: const EdgeInsets.only(left: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 2,
|
||||
children: [
|
||||
Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
ProfilePicture(
|
||||
userLogin: streamInfo.userLogin,
|
||||
radius: 10,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
ProfilePicture(userLogin: streamInfo.userLogin, radius: 10),
|
||||
Flexible(
|
||||
child: Tooltip(
|
||||
message: 'Streamer: $streamerName',
|
||||
message: streamerName,
|
||||
preferBelow: false,
|
||||
child: Text(
|
||||
streamerName,
|
||||
|
|
@ -141,12 +150,11 @@ class StreamCard extends StatelessWidget {
|
|||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Tooltip(
|
||||
message: 'Title: ${streamInfo.title.trim()}',
|
||||
message: streamTitle,
|
||||
preferBelow: false,
|
||||
child: Text(
|
||||
streamInfo.title.trim(),
|
||||
streamTitle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: subFontSize,
|
||||
|
|
@ -154,27 +162,22 @@ class StreamCard extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
if (showCategory) ...[
|
||||
InkWell(
|
||||
onTap: streamInfo.gameName.isNotEmpty
|
||||
? () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CategoryStreams(
|
||||
categoryId: streamInfo.gameId,
|
||||
),
|
||||
builder: (context) =>
|
||||
CategoryStreams(categoryId: streamInfo.gameId),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
child: Tooltip(
|
||||
message:
|
||||
'Category: ${streamInfo.gameName.isNotEmpty ? streamInfo.gameName : 'None'}',
|
||||
message: category,
|
||||
preferBelow: false,
|
||||
child: Text(
|
||||
streamInfo.gameName.isNotEmpty
|
||||
? streamInfo.gameName
|
||||
: 'No Category',
|
||||
category,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: subFontSize,
|
||||
|
|
@ -183,7 +186,6 @@ class StreamCard extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
],
|
||||
Text(
|
||||
'${NumberFormat().format(streamInfo.viewerCount)} viewers',
|
||||
|
|
@ -211,7 +213,7 @@ class StreamCard extends StatelessWidget {
|
|||
onLongPress: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
|
||||
showModalBottomSheet(
|
||||
showModalBottomSheetWithProperFocus(
|
||||
context: context,
|
||||
builder: (context) => UserActionsModal(
|
||||
authStore: context.read<AuthStore>(),
|
||||
|
|
@ -224,20 +226,20 @@ class StreamCard extends StatelessWidget {
|
|||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: 8,
|
||||
horizontal: showThumbnail ? 16 : 4,
|
||||
padding: EdgeInsets.only(
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
left: showThumbnail
|
||||
? 16 + MediaQuery.of(context).padding.left
|
||||
: 4 + MediaQuery.of(context).padding.left,
|
||||
right: showThumbnail
|
||||
? 16 + MediaQuery.of(context).padding.right
|
||||
: 4 + MediaQuery.of(context).padding.right,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (showThumbnail)
|
||||
Flexible(
|
||||
child: imageSection,
|
||||
),
|
||||
Flexible(
|
||||
flex: 2,
|
||||
child: streamInfoSection,
|
||||
),
|
||||
if (showThumbnail) Flexible(child: imageSection),
|
||||
Flexible(flex: 2, child: streamInfoSection),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:frosty/apis/base_api_client.dart';
|
||||
import 'package:frosty/apis/twitch_api.dart';
|
||||
import 'package:frosty/models/category.dart';
|
||||
import 'package:frosty/models/followed_channel.dart';
|
||||
import 'package:frosty/models/stream.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
|
|
@ -34,6 +36,9 @@ abstract class ListStoreBase with Store {
|
|||
/// The pagination cursor for the streams.
|
||||
String? _streamsCursor;
|
||||
|
||||
/// The pagination cursor for the offline followed channels.
|
||||
String? _offlineChannelsCursor;
|
||||
|
||||
/// The last time the streams were refreshed/updated.
|
||||
var lastTimeRefreshed = DateTime.now();
|
||||
|
||||
|
|
@ -45,7 +50,8 @@ abstract class ListStoreBase with Store {
|
|||
bool get isLoading =>
|
||||
_isAllStreamsLoading ||
|
||||
_isPinnedStreamsLoading ||
|
||||
_isCategoryDetailsLoading;
|
||||
_isCategoryDetailsLoading ||
|
||||
_isOfflineChannelsLoading;
|
||||
|
||||
/// The loading status for pagination.
|
||||
|
||||
|
|
@ -60,7 +66,7 @@ abstract class ListStoreBase with Store {
|
|||
var _pinnedStreams = ObservableList<StreamTwitch>();
|
||||
|
||||
@readonly
|
||||
var _isPinnedStreamsLoading = false;
|
||||
bool _isPinnedStreamsLoading = false;
|
||||
|
||||
@readonly
|
||||
CategoryTwitch? _categoryDetails;
|
||||
|
|
@ -68,10 +74,26 @@ abstract class ListStoreBase with Store {
|
|||
@readonly
|
||||
var _isCategoryDetailsLoading = false;
|
||||
|
||||
/// The list of offline followed channels.
|
||||
@readonly
|
||||
var _allOfflineChannels = ObservableList<FollowedChannel>();
|
||||
|
||||
@readonly
|
||||
bool _isOfflineChannelsLoading = false;
|
||||
|
||||
/// Whether or not there are more offline channels for pagination.
|
||||
@computed
|
||||
bool get hasMoreOfflineChannels =>
|
||||
_isOfflineChannelsLoading == false && _offlineChannelsCursor != null;
|
||||
|
||||
/// Whether or not the scroll to top button is visible.
|
||||
@observable
|
||||
var showJumpButton = false;
|
||||
|
||||
/// Whether the offline channels section is expanded.
|
||||
@observable
|
||||
var isOfflineChannelsExpanded = false;
|
||||
|
||||
/// The list of the fetched streams with blocked users filtered out.
|
||||
@computed
|
||||
ObservableList<StreamTwitch> get streams => _allStreams
|
||||
|
|
@ -83,11 +105,46 @@ abstract class ListStoreBase with Store {
|
|||
.toList()
|
||||
.asObservable();
|
||||
|
||||
/// All pinned channels (only live streams).
|
||||
@computed
|
||||
List<dynamic> get allPinnedChannels {
|
||||
final blockedUserIds = authStore.user.blockedUsers
|
||||
.map((blockedUser) => blockedUser.userId)
|
||||
.toSet();
|
||||
|
||||
// Get live pinned streams only
|
||||
return _pinnedStreams
|
||||
.where((stream) => !blockedUserIds.contains(stream.userId))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// The list of offline followed channels with blocked users filtered out
|
||||
/// and excluding channels that are currently live or pinned.
|
||||
@computed
|
||||
ObservableList<FollowedChannel> get offlineChannels {
|
||||
final liveChannelIds = streams.map((stream) => stream.userId).toSet();
|
||||
final pinnedChannelIds = settingsStore.pinnedChannelIds.toSet();
|
||||
final blockedUserIds = authStore.user.blockedUsers
|
||||
.map((blockedUser) => blockedUser.userId)
|
||||
.toSet();
|
||||
|
||||
return _allOfflineChannels
|
||||
.where(
|
||||
(channel) =>
|
||||
!liveChannelIds.contains(channel.broadcasterId) &&
|
||||
!pinnedChannelIds.contains(channel.broadcasterId) &&
|
||||
!blockedUserIds.contains(channel.broadcasterId),
|
||||
)
|
||||
.toList()
|
||||
.asObservable();
|
||||
}
|
||||
|
||||
/// The error message to show if any. Will be non-null if there is an error.
|
||||
@readonly
|
||||
String? _error;
|
||||
|
||||
ReactionDisposer? _pinnedStreamsReactioniDisposer;
|
||||
ReactionDisposer? _offlineChannelsExpansionDisposer;
|
||||
|
||||
ListStoreBase({
|
||||
required this.authStore,
|
||||
|
|
@ -115,6 +172,23 @@ abstract class ListStoreBase with Store {
|
|||
);
|
||||
|
||||
getPinnedStreams();
|
||||
|
||||
// Fetch offline channels only when expanded
|
||||
_offlineChannelsExpansionDisposer = reaction(
|
||||
(_) => isOfflineChannelsExpanded,
|
||||
(expanded) {
|
||||
if (expanded) {
|
||||
// Fetch offline channels when expanded (only if not already loaded)
|
||||
if (_allOfflineChannels.isEmpty) {
|
||||
getOfflineChannels();
|
||||
}
|
||||
} else {
|
||||
// Clear offline channels when collapsed to save memory
|
||||
_allOfflineChannels.clear();
|
||||
_offlineChannelsCursor = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (listType == ListType.category) {
|
||||
|
|
@ -135,20 +209,15 @@ abstract class ListStoreBase with Store {
|
|||
case ListType.followed:
|
||||
newStreams = await twitchApi.getFollowedStreams(
|
||||
id: authStore.user.details!.id,
|
||||
headers: authStore.headersTwitch,
|
||||
cursor: _streamsCursor,
|
||||
);
|
||||
break;
|
||||
case ListType.top:
|
||||
newStreams = await twitchApi.getTopStreams(
|
||||
headers: authStore.headersTwitch,
|
||||
cursor: _streamsCursor,
|
||||
);
|
||||
newStreams = await twitchApi.getTopStreams(cursor: _streamsCursor);
|
||||
break;
|
||||
case ListType.category:
|
||||
newStreams = await twitchApi.getStreamsUnderCategory(
|
||||
gameId: categoryId!,
|
||||
headers: authStore.headersTwitch,
|
||||
cursor: _streamsCursor,
|
||||
);
|
||||
break;
|
||||
|
|
@ -163,9 +232,14 @@ abstract class ListStoreBase with Store {
|
|||
|
||||
_error = null;
|
||||
} on SocketException {
|
||||
_error = 'Failed to connect';
|
||||
_error = 'Unable to connect to Twitch';
|
||||
debugPrint('Streams SocketException: No internet connection');
|
||||
} on ApiException catch (e) {
|
||||
_error = e.message;
|
||||
debugPrint('Streams ApiException: $e');
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
_error = 'Something went wrong loading streams';
|
||||
debugPrint('Streams error: $e');
|
||||
}
|
||||
|
||||
_isAllStreamsLoading = false;
|
||||
|
|
@ -183,25 +257,67 @@ abstract class ListStoreBase with Store {
|
|||
try {
|
||||
_pinnedStreams = (await twitchApi.getStreamsByIds(
|
||||
userIds: settingsStore.pinnedChannelIds,
|
||||
headers: authStore.headersTwitch,
|
||||
))
|
||||
.data
|
||||
.asObservable();
|
||||
)).data.asObservable();
|
||||
|
||||
_error = null;
|
||||
} on SocketException {
|
||||
_error = 'Failed to connect';
|
||||
_error = 'Unable to connect to Twitch';
|
||||
debugPrint('Pinned streams SocketException: No internet connection');
|
||||
} on ApiException catch (e) {
|
||||
_error = e.message;
|
||||
debugPrint('Pinned streams ApiException: $e');
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
_error = 'Something went wrong loading pinned streams';
|
||||
debugPrint('Pinned streams error: $e');
|
||||
}
|
||||
|
||||
_isPinnedStreamsLoading = false;
|
||||
}
|
||||
|
||||
@action
|
||||
Future<void> getOfflineChannels() async {
|
||||
_isOfflineChannelsLoading = true;
|
||||
|
||||
try {
|
||||
final followedChannels = await twitchApi.getFollowedChannels(
|
||||
userId: authStore.user.details!.id,
|
||||
cursor: _offlineChannelsCursor,
|
||||
);
|
||||
|
||||
if (_offlineChannelsCursor == null) {
|
||||
_allOfflineChannels = followedChannels.data.asObservable();
|
||||
} else {
|
||||
_allOfflineChannels.addAll(followedChannels.data);
|
||||
}
|
||||
_offlineChannelsCursor = followedChannels.pagination['cursor'];
|
||||
|
||||
_error = null;
|
||||
} on SocketException {
|
||||
_error = 'Unable to connect to Twitch';
|
||||
debugPrint('Offline channels SocketException: No internet connection');
|
||||
} on ApiException catch (e) {
|
||||
_error = e.message;
|
||||
debugPrint('Offline channels ApiException: $e');
|
||||
} catch (e) {
|
||||
_error = 'Something went wrong loading offline channels';
|
||||
debugPrint('Offline channels error: $e');
|
||||
}
|
||||
|
||||
_isOfflineChannelsLoading = false;
|
||||
}
|
||||
|
||||
/// Resets the cursor and then fetches the streams.
|
||||
@action
|
||||
Future<void> refreshStreams() async {
|
||||
if (listType == ListType.followed) await getPinnedStreams();
|
||||
if (listType == ListType.followed) {
|
||||
await getPinnedStreams();
|
||||
|
||||
// Only refresh offline channels if they are currently expanded
|
||||
if (isOfflineChannelsExpanded) {
|
||||
_offlineChannelsCursor = null;
|
||||
await getOfflineChannels();
|
||||
}
|
||||
}
|
||||
|
||||
_streamsCursor = null;
|
||||
await getStreams();
|
||||
|
|
@ -213,10 +329,7 @@ abstract class ListStoreBase with Store {
|
|||
|
||||
_isCategoryDetailsLoading = true;
|
||||
|
||||
final categoryDetails = await twitchApi.getCategory(
|
||||
headers: authStore.headersTwitch,
|
||||
gameId: categoryId!,
|
||||
);
|
||||
final categoryDetails = await twitchApi.getCategory(gameId: categoryId!);
|
||||
|
||||
_categoryDetails = categoryDetails.data.first;
|
||||
|
||||
|
|
@ -235,6 +348,7 @@ abstract class ListStoreBase with Store {
|
|||
|
||||
void dispose() {
|
||||
_pinnedStreamsReactioniDisposer?.call();
|
||||
_offlineChannelsExpansionDisposer?.call();
|
||||
|
||||
scrollController?.dispose();
|
||||
}
|
||||
|
|
@ -245,8 +359,4 @@ abstract class ListStoreBase with Store {
|
|||
/// [ListType.followed] is the list of streams that the user is following.
|
||||
/// [ListType.top] is the list of top streams.
|
||||
/// [ListType.category] is the list of streams under a category.
|
||||
enum ListType {
|
||||
followed,
|
||||
top,
|
||||
category,
|
||||
}
|
||||
enum ListType { followed, top, category }
|
||||
|
|
|
|||
|
|
@ -12,26 +12,54 @@ mixin _$ListStore on ListStoreBase, Store {
|
|||
Computed<bool>? _$hasMoreComputed;
|
||||
|
||||
@override
|
||||
bool get hasMore => (_$hasMoreComputed ??=
|
||||
Computed<bool>(() => super.hasMore, name: 'ListStoreBase.hasMore'))
|
||||
.value;
|
||||
bool get hasMore => (_$hasMoreComputed ??= Computed<bool>(
|
||||
() => super.hasMore,
|
||||
name: 'ListStoreBase.hasMore',
|
||||
)).value;
|
||||
Computed<bool>? _$isLoadingComputed;
|
||||
|
||||
@override
|
||||
bool get isLoading =>
|
||||
(_$isLoadingComputed ??= Computed<bool>(() => super.isLoading,
|
||||
name: 'ListStoreBase.isLoading'))
|
||||
.value;
|
||||
bool get isLoading => (_$isLoadingComputed ??= Computed<bool>(
|
||||
() => super.isLoading,
|
||||
name: 'ListStoreBase.isLoading',
|
||||
)).value;
|
||||
Computed<bool>? _$hasMoreOfflineChannelsComputed;
|
||||
|
||||
@override
|
||||
bool get hasMoreOfflineChannels =>
|
||||
(_$hasMoreOfflineChannelsComputed ??= Computed<bool>(
|
||||
() => super.hasMoreOfflineChannels,
|
||||
name: 'ListStoreBase.hasMoreOfflineChannels',
|
||||
)).value;
|
||||
Computed<ObservableList<StreamTwitch>>? _$streamsComputed;
|
||||
|
||||
@override
|
||||
ObservableList<StreamTwitch> get streams => (_$streamsComputed ??=
|
||||
Computed<ObservableList<StreamTwitch>>(() => super.streams,
|
||||
name: 'ListStoreBase.streams'))
|
||||
.value;
|
||||
ObservableList<StreamTwitch> get streams =>
|
||||
(_$streamsComputed ??= Computed<ObservableList<StreamTwitch>>(
|
||||
() => super.streams,
|
||||
name: 'ListStoreBase.streams',
|
||||
)).value;
|
||||
Computed<List<dynamic>>? _$allPinnedChannelsComputed;
|
||||
|
||||
late final _$_allStreamsAtom =
|
||||
Atom(name: 'ListStoreBase._allStreams', context: context);
|
||||
@override
|
||||
List<dynamic> get allPinnedChannels =>
|
||||
(_$allPinnedChannelsComputed ??= Computed<List<dynamic>>(
|
||||
() => super.allPinnedChannels,
|
||||
name: 'ListStoreBase.allPinnedChannels',
|
||||
)).value;
|
||||
Computed<ObservableList<FollowedChannel>>? _$offlineChannelsComputed;
|
||||
|
||||
@override
|
||||
ObservableList<FollowedChannel> get offlineChannels =>
|
||||
(_$offlineChannelsComputed ??= Computed<ObservableList<FollowedChannel>>(
|
||||
() => super.offlineChannels,
|
||||
name: 'ListStoreBase.offlineChannels',
|
||||
)).value;
|
||||
|
||||
late final _$_allStreamsAtom = Atom(
|
||||
name: 'ListStoreBase._allStreams',
|
||||
context: context,
|
||||
);
|
||||
|
||||
ObservableList<StreamTwitch> get allStreams {
|
||||
_$_allStreamsAtom.reportRead();
|
||||
|
|
@ -48,8 +76,10 @@ mixin _$ListStore on ListStoreBase, Store {
|
|||
});
|
||||
}
|
||||
|
||||
late final _$_isAllStreamsLoadingAtom =
|
||||
Atom(name: 'ListStoreBase._isAllStreamsLoading', context: context);
|
||||
late final _$_isAllStreamsLoadingAtom = Atom(
|
||||
name: 'ListStoreBase._isAllStreamsLoading',
|
||||
context: context,
|
||||
);
|
||||
|
||||
bool get isAllStreamsLoading {
|
||||
_$_isAllStreamsLoadingAtom.reportRead();
|
||||
|
|
@ -61,14 +91,19 @@ mixin _$ListStore on ListStoreBase, Store {
|
|||
|
||||
@override
|
||||
set _isAllStreamsLoading(bool value) {
|
||||
_$_isAllStreamsLoadingAtom.reportWrite(value, super._isAllStreamsLoading,
|
||||
_$_isAllStreamsLoadingAtom.reportWrite(
|
||||
value,
|
||||
super._isAllStreamsLoading,
|
||||
() {
|
||||
super._isAllStreamsLoading = value;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
late final _$_pinnedStreamsAtom =
|
||||
Atom(name: 'ListStoreBase._pinnedStreams', context: context);
|
||||
late final _$_pinnedStreamsAtom = Atom(
|
||||
name: 'ListStoreBase._pinnedStreams',
|
||||
context: context,
|
||||
);
|
||||
|
||||
ObservableList<StreamTwitch> get pinnedStreams {
|
||||
_$_pinnedStreamsAtom.reportRead();
|
||||
|
|
@ -85,8 +120,10 @@ mixin _$ListStore on ListStoreBase, Store {
|
|||
});
|
||||
}
|
||||
|
||||
late final _$_isPinnedStreamsLoadingAtom =
|
||||
Atom(name: 'ListStoreBase._isPinnedStreamsLoading', context: context);
|
||||
late final _$_isPinnedStreamsLoadingAtom = Atom(
|
||||
name: 'ListStoreBase._isPinnedStreamsLoading',
|
||||
context: context,
|
||||
);
|
||||
|
||||
bool get isPinnedStreamsLoading {
|
||||
_$_isPinnedStreamsLoadingAtom.reportRead();
|
||||
|
|
@ -98,14 +135,19 @@ mixin _$ListStore on ListStoreBase, Store {
|
|||
|
||||
@override
|
||||
set _isPinnedStreamsLoading(bool value) {
|
||||
_$_isPinnedStreamsLoadingAtom
|
||||
.reportWrite(value, super._isPinnedStreamsLoading, () {
|
||||
_$_isPinnedStreamsLoadingAtom.reportWrite(
|
||||
value,
|
||||
super._isPinnedStreamsLoading,
|
||||
() {
|
||||
super._isPinnedStreamsLoading = value;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
late final _$_categoryDetailsAtom =
|
||||
Atom(name: 'ListStoreBase._categoryDetails', context: context);
|
||||
late final _$_categoryDetailsAtom = Atom(
|
||||
name: 'ListStoreBase._categoryDetails',
|
||||
context: context,
|
||||
);
|
||||
|
||||
CategoryTwitch? get categoryDetails {
|
||||
_$_categoryDetailsAtom.reportRead();
|
||||
|
|
@ -122,8 +164,10 @@ mixin _$ListStore on ListStoreBase, Store {
|
|||
});
|
||||
}
|
||||
|
||||
late final _$_isCategoryDetailsLoadingAtom =
|
||||
Atom(name: 'ListStoreBase._isCategoryDetailsLoading', context: context);
|
||||
late final _$_isCategoryDetailsLoadingAtom = Atom(
|
||||
name: 'ListStoreBase._isCategoryDetailsLoading',
|
||||
context: context,
|
||||
);
|
||||
|
||||
bool get isCategoryDetailsLoading {
|
||||
_$_isCategoryDetailsLoadingAtom.reportRead();
|
||||
|
|
@ -135,14 +179,63 @@ mixin _$ListStore on ListStoreBase, Store {
|
|||
|
||||
@override
|
||||
set _isCategoryDetailsLoading(bool value) {
|
||||
_$_isCategoryDetailsLoadingAtom
|
||||
.reportWrite(value, super._isCategoryDetailsLoading, () {
|
||||
_$_isCategoryDetailsLoadingAtom.reportWrite(
|
||||
value,
|
||||
super._isCategoryDetailsLoading,
|
||||
() {
|
||||
super._isCategoryDetailsLoading = value;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
late final _$_allOfflineChannelsAtom = Atom(
|
||||
name: 'ListStoreBase._allOfflineChannels',
|
||||
context: context,
|
||||
);
|
||||
|
||||
ObservableList<FollowedChannel> get allOfflineChannels {
|
||||
_$_allOfflineChannelsAtom.reportRead();
|
||||
return super._allOfflineChannels;
|
||||
}
|
||||
|
||||
@override
|
||||
ObservableList<FollowedChannel> get _allOfflineChannels => allOfflineChannels;
|
||||
|
||||
@override
|
||||
set _allOfflineChannels(ObservableList<FollowedChannel> value) {
|
||||
_$_allOfflineChannelsAtom.reportWrite(value, super._allOfflineChannels, () {
|
||||
super._allOfflineChannels = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$showJumpButtonAtom =
|
||||
Atom(name: 'ListStoreBase.showJumpButton', context: context);
|
||||
late final _$_isOfflineChannelsLoadingAtom = Atom(
|
||||
name: 'ListStoreBase._isOfflineChannelsLoading',
|
||||
context: context,
|
||||
);
|
||||
|
||||
bool get isOfflineChannelsLoading {
|
||||
_$_isOfflineChannelsLoadingAtom.reportRead();
|
||||
return super._isOfflineChannelsLoading;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get _isOfflineChannelsLoading => isOfflineChannelsLoading;
|
||||
|
||||
@override
|
||||
set _isOfflineChannelsLoading(bool value) {
|
||||
_$_isOfflineChannelsLoadingAtom.reportWrite(
|
||||
value,
|
||||
super._isOfflineChannelsLoading,
|
||||
() {
|
||||
super._isOfflineChannelsLoading = value;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
late final _$showJumpButtonAtom = Atom(
|
||||
name: 'ListStoreBase.showJumpButton',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
bool get showJumpButton {
|
||||
|
|
@ -157,8 +250,32 @@ mixin _$ListStore on ListStoreBase, Store {
|
|||
});
|
||||
}
|
||||
|
||||
late final _$_errorAtom =
|
||||
Atom(name: 'ListStoreBase._error', context: context);
|
||||
late final _$isOfflineChannelsExpandedAtom = Atom(
|
||||
name: 'ListStoreBase.isOfflineChannelsExpanded',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
bool get isOfflineChannelsExpanded {
|
||||
_$isOfflineChannelsExpandedAtom.reportRead();
|
||||
return super.isOfflineChannelsExpanded;
|
||||
}
|
||||
|
||||
@override
|
||||
set isOfflineChannelsExpanded(bool value) {
|
||||
_$isOfflineChannelsExpandedAtom.reportWrite(
|
||||
value,
|
||||
super.isOfflineChannelsExpanded,
|
||||
() {
|
||||
super.isOfflineChannelsExpanded = value;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
late final _$_errorAtom = Atom(
|
||||
name: 'ListStoreBase._error',
|
||||
context: context,
|
||||
);
|
||||
|
||||
String? get error {
|
||||
_$_errorAtom.reportRead();
|
||||
|
|
@ -175,46 +292,71 @@ mixin _$ListStore on ListStoreBase, Store {
|
|||
});
|
||||
}
|
||||
|
||||
late final _$getStreamsAsyncAction =
|
||||
AsyncAction('ListStoreBase.getStreams', context: context);
|
||||
late final _$getStreamsAsyncAction = AsyncAction(
|
||||
'ListStoreBase.getStreams',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> getStreams() {
|
||||
return _$getStreamsAsyncAction.run(() => super.getStreams());
|
||||
}
|
||||
|
||||
late final _$getPinnedStreamsAsyncAction =
|
||||
AsyncAction('ListStoreBase.getPinnedStreams', context: context);
|
||||
late final _$getPinnedStreamsAsyncAction = AsyncAction(
|
||||
'ListStoreBase.getPinnedStreams',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> getPinnedStreams() {
|
||||
return _$getPinnedStreamsAsyncAction.run(() => super.getPinnedStreams());
|
||||
}
|
||||
|
||||
late final _$refreshStreamsAsyncAction =
|
||||
AsyncAction('ListStoreBase.refreshStreams', context: context);
|
||||
late final _$getOfflineChannelsAsyncAction = AsyncAction(
|
||||
'ListStoreBase.getOfflineChannels',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> getOfflineChannels() {
|
||||
return _$getOfflineChannelsAsyncAction.run(
|
||||
() => super.getOfflineChannels(),
|
||||
);
|
||||
}
|
||||
|
||||
late final _$refreshStreamsAsyncAction = AsyncAction(
|
||||
'ListStoreBase.refreshStreams',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> refreshStreams() {
|
||||
return _$refreshStreamsAsyncAction.run(() => super.refreshStreams());
|
||||
}
|
||||
|
||||
late final _$_getCategoryDetailsAsyncAction =
|
||||
AsyncAction('ListStoreBase._getCategoryDetails', context: context);
|
||||
late final _$_getCategoryDetailsAsyncAction = AsyncAction(
|
||||
'ListStoreBase._getCategoryDetails',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> _getCategoryDetails() {
|
||||
return _$_getCategoryDetailsAsyncAction
|
||||
.run(() => super._getCategoryDetails());
|
||||
return _$_getCategoryDetailsAsyncAction.run(
|
||||
() => super._getCategoryDetails(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''
|
||||
showJumpButton: ${showJumpButton},
|
||||
isOfflineChannelsExpanded: ${isOfflineChannelsExpanded},
|
||||
hasMore: ${hasMore},
|
||||
isLoading: ${isLoading},
|
||||
streams: ${streams}
|
||||
hasMoreOfflineChannels: ${hasMoreOfflineChannels},
|
||||
streams: ${streams},
|
||||
allPinnedChannels: ${allPinnedChannels},
|
||||
offlineChannels: ${offlineChannels}
|
||||
''';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,17 +2,19 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
import 'package:frosty/apis/twitch_api.dart';
|
||||
import 'package:frosty/models/stream.dart';
|
||||
import 'package:frosty/screens/home/stream_list/large_stream_card.dart';
|
||||
import 'package:frosty/screens/home/stream_list/offline_channel_card.dart';
|
||||
import 'package:frosty/screens/home/stream_list/stream_card.dart';
|
||||
import 'package:frosty/screens/home/stream_list/stream_list_store.dart';
|
||||
import 'package:frosty/screens/home/top/categories/category_card.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
import 'package:frosty/widgets/alert_message.dart';
|
||||
import 'package:frosty/widgets/animated_scroll_border.dart';
|
||||
import 'package:frosty/widgets/loading_indicator.dart';
|
||||
import 'package:frosty/widgets/expandable_section_header.dart';
|
||||
import 'package:frosty/widgets/frosty_scrollbar.dart';
|
||||
import 'package:frosty/widgets/scroll_to_top_button.dart';
|
||||
import 'package:frosty/widgets/section_header.dart';
|
||||
import 'package:frosty/widgets/skeleton_loader.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// A widget that displays a list of followed or top streams based on the provided [listType].
|
||||
|
|
@ -89,7 +91,16 @@ class _StreamsListState extends State<StreamsList>
|
|||
|
||||
_listStore.checkLastTimeRefreshedAndUpdate();
|
||||
|
||||
final extraTopPadding = widget.listType == ListType.top
|
||||
? kToolbarHeight
|
||||
: widget.listType == ListType.category
|
||||
? kToolbarHeight + 122
|
||||
: 0.0;
|
||||
|
||||
final topPadding = MediaQuery.of(context).padding.top + extraTopPadding;
|
||||
|
||||
return RefreshIndicator.adaptive(
|
||||
edgeOffset: topPadding,
|
||||
onRefresh: () async {
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
|
|
@ -97,10 +108,7 @@ class _StreamsListState extends State<StreamsList>
|
|||
|
||||
if (_listStore.error != null) {
|
||||
final snackBar = SnackBar(
|
||||
content: AlertMessage(
|
||||
message: _listStore.error!,
|
||||
centered: false,
|
||||
),
|
||||
content: AlertMessage(message: _listStore.error!, centered: false),
|
||||
);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
|
@ -113,18 +121,98 @@ class _StreamsListState extends State<StreamsList>
|
|||
Widget? statusWidget;
|
||||
|
||||
if (_listStore.error != null) {
|
||||
statusWidget = AlertMessage(message: _listStore.error!);
|
||||
statusWidget = AlertMessage(
|
||||
message: _listStore.error!,
|
||||
vertical: true,
|
||||
);
|
||||
}
|
||||
|
||||
if (_listStore.streams.isEmpty) {
|
||||
if (_listStore.isLoading && _listStore.error == null) {
|
||||
statusWidget =
|
||||
const LoadingIndicator(subtitle: 'Loading streams...');
|
||||
// Show skeleton loaders while loading
|
||||
final settingsStore = context.watch<SettingsStore>();
|
||||
final isLargeCard = settingsStore.largeStreamCard;
|
||||
final isFollowingTab = widget.listType == ListType.followed;
|
||||
final pinnedChannelCount = settingsStore.pinnedChannelIds.length;
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// Add padding for app bar
|
||||
SliverTopPadding(extraTopPadding: extraTopPadding),
|
||||
|
||||
// Show pinned section if following tab and has pinned channels
|
||||
if (isFollowingTab && pinnedChannelCount > 0) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: Builder(
|
||||
builder: (context) => SectionHeader(
|
||||
'Pinned',
|
||||
isFirst: true,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16 + MediaQuery.of(context).padding.left,
|
||||
8,
|
||||
16 + MediaQuery.of(context).padding.right,
|
||||
8,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverList.builder(
|
||||
itemCount: pinnedChannelCount,
|
||||
itemBuilder: (context, index) {
|
||||
if (isLargeCard) {
|
||||
return LargeStreamCardSkeletonLoader(
|
||||
showThumbnail: settingsStore.showThumbnails,
|
||||
showCategory: widget.listType != ListType.category,
|
||||
);
|
||||
} else {
|
||||
return StreamCardSkeletonLoader(
|
||||
showThumbnail: settingsStore.showThumbnails,
|
||||
showCategory: widget.listType != ListType.category,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Builder(
|
||||
builder: (context) => SectionHeader(
|
||||
'All',
|
||||
isFirst: true,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16 + MediaQuery.of(context).padding.left,
|
||||
8,
|
||||
16 + MediaQuery.of(context).padding.right,
|
||||
8,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
SliverList.builder(
|
||||
itemCount: isFollowingTab && pinnedChannelCount > 0 ? 5 : 8,
|
||||
itemBuilder: (context, index) {
|
||||
if (isLargeCard) {
|
||||
return LargeStreamCardSkeletonLoader(
|
||||
showThumbnail: settingsStore.showThumbnails,
|
||||
showCategory: widget.listType != ListType.category,
|
||||
);
|
||||
} else {
|
||||
return StreamCardSkeletonLoader(
|
||||
showThumbnail: settingsStore.showThumbnails,
|
||||
showCategory: widget.listType != ListType.category,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
SliverBottomPadding(),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
statusWidget = AlertMessage(
|
||||
message: widget.listType == ListType.followed
|
||||
? 'No followed streams'
|
||||
: 'No top streams',
|
||||
vertical: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -132,23 +220,22 @@ class _StreamsListState extends State<StreamsList>
|
|||
if (statusWidget != null) {
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: statusWidget,
|
||||
),
|
||||
),
|
||||
SliverFillRemaining(child: Center(child: statusWidget)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final settingsStore = context.watch<SettingsStore>();
|
||||
|
||||
final unpinnedStreams = _listStore.streams
|
||||
final unpinnedStreams = widget.listType == ListType.followed
|
||||
? _listStore.streams
|
||||
.where(
|
||||
(stream) =>
|
||||
!settingsStore.pinnedChannelIds.contains(stream.userId),
|
||||
(stream) => !settingsStore.pinnedChannelIds.contains(
|
||||
stream.userId,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
.toList()
|
||||
: _listStore.streams;
|
||||
|
||||
final isFollowingTab = widget.listType == ListType.followed;
|
||||
|
||||
|
|
@ -157,50 +244,53 @@ class _StreamsListState extends State<StreamsList>
|
|||
children: [
|
||||
Column(
|
||||
children: [
|
||||
if (widget.categoryId != null &&
|
||||
_listStore.categoryDetails != null)
|
||||
CategoryCard(
|
||||
category: _listStore.categoryDetails!,
|
||||
isTappable: false,
|
||||
),
|
||||
if (_listStore.scrollController != null)
|
||||
AnimatedScrollBorder(
|
||||
scrollController: _listStore.scrollController!,
|
||||
),
|
||||
Expanded(
|
||||
child: Scrollbar(
|
||||
child: FrostyScrollbar(
|
||||
controller: _listStore.scrollController,
|
||||
padding: EdgeInsets.only(
|
||||
top:
|
||||
MediaQuery.of(context).padding.top +
|
||||
extraTopPadding,
|
||||
bottom: MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: CustomScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
controller: _listStore.scrollController,
|
||||
slivers: [
|
||||
SliverTopPadding(extraTopPadding: extraTopPadding),
|
||||
if (isFollowingTab &&
|
||||
_listStore.pinnedStreams.isNotEmpty) ...[
|
||||
const SliverToBoxAdapter(
|
||||
child: SectionHeader(
|
||||
_listStore.allPinnedChannels.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: Builder(
|
||||
builder: (context) => SectionHeader(
|
||||
'Pinned',
|
||||
isFirst: true,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
12,
|
||||
16,
|
||||
16 + MediaQuery.of(context).padding.left,
|
||||
8,
|
||||
16 + MediaQuery.of(context).padding.right,
|
||||
8,
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverList.builder(
|
||||
itemCount: _listStore.pinnedStreams.length,
|
||||
),
|
||||
Observer(
|
||||
builder: (context) {
|
||||
// Show actual pinned channels (live streams only)
|
||||
return SliverList.builder(
|
||||
itemCount:
|
||||
_listStore.allPinnedChannels.length,
|
||||
itemBuilder: (context, index) {
|
||||
final stream =
|
||||
_listStore.allPinnedChannels[index]
|
||||
as StreamTwitch;
|
||||
return Observer(
|
||||
builder: (context) {
|
||||
// Only live stream cards since we removed offline pinned channels
|
||||
return settingsStore.largeStreamCard
|
||||
? LargeStreamCard(
|
||||
key: ValueKey(
|
||||
_listStore
|
||||
.pinnedStreams[index].userId,
|
||||
),
|
||||
streamInfo:
|
||||
_listStore.pinnedStreams[index],
|
||||
key: ValueKey(stream.userId),
|
||||
streamInfo: stream,
|
||||
showThumbnail: context
|
||||
.read<SettingsStore>()
|
||||
.showThumbnails,
|
||||
|
|
@ -210,12 +300,8 @@ class _StreamsListState extends State<StreamsList>
|
|||
isPinned: true,
|
||||
)
|
||||
: StreamCard(
|
||||
key: ValueKey(
|
||||
_listStore
|
||||
.pinnedStreams[index].userId,
|
||||
),
|
||||
streamInfo:
|
||||
_listStore.pinnedStreams[index],
|
||||
key: ValueKey(stream.userId),
|
||||
streamInfo: stream,
|
||||
showThumbnail: context
|
||||
.read<SettingsStore>()
|
||||
.showThumbnails,
|
||||
|
|
@ -227,19 +313,23 @@ class _StreamsListState extends State<StreamsList>
|
|||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const SliverToBoxAdapter(
|
||||
child: SectionHeader(
|
||||
SliverToBoxAdapter(
|
||||
child: Builder(
|
||||
builder: (context) => SectionHeader(
|
||||
'All',
|
||||
isFirst: true,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
16 + MediaQuery.of(context).padding.left,
|
||||
8,
|
||||
16,
|
||||
16 + MediaQuery.of(context).padding.right,
|
||||
8,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
SliverList.builder(
|
||||
itemCount: unpinnedStreams.length,
|
||||
|
|
@ -249,8 +339,8 @@ class _StreamsListState extends State<StreamsList>
|
|||
_listStore.getStreams();
|
||||
}
|
||||
return Observer(
|
||||
builder: (context) => settingsStore
|
||||
.largeStreamCard
|
||||
builder: (context) =>
|
||||
settingsStore.largeStreamCard
|
||||
? LargeStreamCard(
|
||||
key: ValueKey(
|
||||
unpinnedStreams[index].userId,
|
||||
|
|
@ -276,6 +366,57 @@ class _StreamsListState extends State<StreamsList>
|
|||
);
|
||||
},
|
||||
),
|
||||
// Add offline followed channels section for following tab
|
||||
if (isFollowingTab) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: Observer(
|
||||
builder: (context) => ExpandableSectionHeader(
|
||||
'Offline',
|
||||
isFirst: true,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16 + MediaQuery.of(context).padding.left,
|
||||
16,
|
||||
16 + MediaQuery.of(context).padding.right,
|
||||
8,
|
||||
),
|
||||
isExpanded:
|
||||
_listStore.isOfflineChannelsExpanded,
|
||||
onToggle: () =>
|
||||
_listStore.isOfflineChannelsExpanded =
|
||||
!_listStore.isOfflineChannelsExpanded,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_listStore.isOfflineChannelsExpanded)
|
||||
SliverList.builder(
|
||||
itemCount: _listStore.offlineChannels.length,
|
||||
itemBuilder: (context, index) {
|
||||
// Load more offline channels when nearing the end
|
||||
if (index >
|
||||
_listStore.offlineChannels.length -
|
||||
5 &&
|
||||
_listStore.hasMoreOfflineChannels) {
|
||||
_listStore.getOfflineChannels();
|
||||
}
|
||||
|
||||
return Observer(
|
||||
builder: (context) => OfflineChannelCard(
|
||||
key: ValueKey(
|
||||
_listStore
|
||||
.offlineChannels[index]
|
||||
.broadcasterId,
|
||||
),
|
||||
channelInfo:
|
||||
_listStore.offlineChannels[index],
|
||||
showPinOption: false,
|
||||
isPinned: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
// Add padding for bottom navigation bar
|
||||
const SliverBottomPadding(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -302,3 +443,31 @@ class _StreamsListState extends State<StreamsList>
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper widget for consistent top padding in sliver lists
|
||||
class SliverTopPadding extends StatelessWidget {
|
||||
final double extraTopPadding;
|
||||
|
||||
const SliverTopPadding({super.key, this.extraTopPadding = 0.0});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).padding.top + extraTopPadding,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper widget for consistent bottom padding in sliver lists
|
||||
class SliverBottomPadding extends StatelessWidget {
|
||||
const SliverBottomPadding({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverToBoxAdapter(
|
||||
child: SizedBox(height: MediaQuery.of(context).padding.bottom),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,21 +2,19 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
import 'package:frosty/apis/twitch_api.dart';
|
||||
import 'package:frosty/screens/home/stream_list/streams_list.dart';
|
||||
import 'package:frosty/screens/home/top/categories/categories_store.dart';
|
||||
import 'package:frosty/screens/home/top/categories/category_card.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/widgets/alert_message.dart';
|
||||
import 'package:frosty/widgets/animated_scroll_border.dart';
|
||||
import 'package:frosty/widgets/loading_indicator.dart';
|
||||
import 'package:frosty/widgets/frosty_scrollbar.dart';
|
||||
import 'package:frosty/widgets/skeleton_loader.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class Categories extends StatefulWidget {
|
||||
final ScrollController scrollController;
|
||||
|
||||
const Categories({
|
||||
super.key,
|
||||
required this.scrollController,
|
||||
});
|
||||
const Categories({super.key, required this.scrollController});
|
||||
|
||||
@override
|
||||
State<Categories> createState() => _CategoriesState();
|
||||
|
|
@ -53,7 +51,10 @@ class _CategoriesState extends State<Categories>
|
|||
|
||||
_categoriesStore.checkLastTimeRefreshedAndUpdate();
|
||||
|
||||
final topPadding = MediaQuery.of(context).padding.top + kToolbarHeight;
|
||||
|
||||
return RefreshIndicator.adaptive(
|
||||
edgeOffset: topPadding,
|
||||
onRefresh: () async {
|
||||
HapticFeedback.lightImpact();
|
||||
await _categoriesStore.refreshCategories();
|
||||
|
|
@ -76,41 +77,55 @@ class _CategoriesState extends State<Categories>
|
|||
Widget? statusWidget;
|
||||
|
||||
if (_categoriesStore.error != null) {
|
||||
statusWidget = AlertMessage(message: _categoriesStore.error!);
|
||||
statusWidget = AlertMessage(
|
||||
message: _categoriesStore.error!,
|
||||
vertical: true,
|
||||
);
|
||||
}
|
||||
|
||||
if (_categoriesStore.categories.isEmpty) {
|
||||
if (_categoriesStore.isLoading && _categoriesStore.error == null) {
|
||||
statusWidget =
|
||||
const LoadingIndicator(subtitle: 'Loading categories...');
|
||||
// Show skeleton loaders while loading
|
||||
return CustomScrollView(
|
||||
controller: widget.scrollController,
|
||||
slivers: [
|
||||
const SliverTopPadding(extraTopPadding: kToolbarHeight),
|
||||
SliverList.builder(
|
||||
itemCount: 10,
|
||||
itemBuilder: (context, index) =>
|
||||
const CategorySkeletonLoader(),
|
||||
),
|
||||
const SliverBottomPadding(),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
statusWidget = const AlertMessage(message: 'No top categories');
|
||||
statusWidget = const AlertMessage(
|
||||
message: 'No top categories',
|
||||
vertical: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (statusWidget != null) {
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: statusWidget,
|
||||
),
|
||||
),
|
||||
SliverFillRemaining(child: Center(child: statusWidget)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
AnimatedScrollBorder(
|
||||
scrollController: widget.scrollController,
|
||||
),
|
||||
Expanded(
|
||||
child: Scrollbar(
|
||||
return FrostyScrollbar(
|
||||
controller: widget.scrollController,
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top + kToolbarHeight,
|
||||
bottom: 64 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: CustomScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
controller: widget.scrollController,
|
||||
slivers: [
|
||||
const SliverTopPadding(extraTopPadding: kToolbarHeight),
|
||||
SliverList.builder(
|
||||
itemCount: _categoriesStore.categories.length,
|
||||
itemBuilder: (context, index) {
|
||||
if (index > _categoriesStore.categories.length - 10 &&
|
||||
|
|
@ -123,9 +138,9 @@ class _CategoriesState extends State<Categories>
|
|||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SliverBottomPadding(),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:frosty/apis/base_api_client.dart';
|
||||
import 'package:frosty/apis/twitch_api.dart';
|
||||
import 'package:frosty/models/category.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
|
|
@ -49,7 +51,6 @@ abstract class CategoriesStoreBase with Store {
|
|||
|
||||
try {
|
||||
final result = await twitchApi.getTopCategories(
|
||||
headers: authStore.headersTwitch,
|
||||
cursor: _categoriesCursor,
|
||||
);
|
||||
|
||||
|
|
@ -62,9 +63,14 @@ abstract class CategoriesStoreBase with Store {
|
|||
|
||||
_error = null;
|
||||
} on SocketException {
|
||||
_error = 'Failed to connect';
|
||||
_error = 'Unable to connect to Twitch';
|
||||
debugPrint('Categories SocketException: No internet connection');
|
||||
} on ApiException catch (e) {
|
||||
_error = e.message;
|
||||
debugPrint('Categories ApiException: $e');
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
_error = 'Something went wrong loading categories';
|
||||
debugPrint('Categories error: $e');
|
||||
}
|
||||
|
||||
_isLoading = false;
|
||||
|
|
|
|||
|
|
@ -12,12 +12,15 @@ mixin _$CategoriesStore on CategoriesStoreBase, Store {
|
|||
Computed<bool>? _$hasMoreComputed;
|
||||
|
||||
@override
|
||||
bool get hasMore => (_$hasMoreComputed ??= Computed<bool>(() => super.hasMore,
|
||||
name: 'CategoriesStoreBase.hasMore'))
|
||||
.value;
|
||||
bool get hasMore => (_$hasMoreComputed ??= Computed<bool>(
|
||||
() => super.hasMore,
|
||||
name: 'CategoriesStoreBase.hasMore',
|
||||
)).value;
|
||||
|
||||
late final _$_isLoadingAtom =
|
||||
Atom(name: 'CategoriesStoreBase._isLoading', context: context);
|
||||
late final _$_isLoadingAtom = Atom(
|
||||
name: 'CategoriesStoreBase._isLoading',
|
||||
context: context,
|
||||
);
|
||||
|
||||
bool get isLoading {
|
||||
_$_isLoadingAtom.reportRead();
|
||||
|
|
@ -34,8 +37,10 @@ mixin _$CategoriesStore on CategoriesStoreBase, Store {
|
|||
});
|
||||
}
|
||||
|
||||
late final _$_categoriesAtom =
|
||||
Atom(name: 'CategoriesStoreBase._categories', context: context);
|
||||
late final _$_categoriesAtom = Atom(
|
||||
name: 'CategoriesStoreBase._categories',
|
||||
context: context,
|
||||
);
|
||||
|
||||
ObservableList<CategoryTwitch> get categories {
|
||||
_$_categoriesAtom.reportRead();
|
||||
|
|
@ -52,8 +57,10 @@ mixin _$CategoriesStore on CategoriesStoreBase, Store {
|
|||
});
|
||||
}
|
||||
|
||||
late final _$_errorAtom =
|
||||
Atom(name: 'CategoriesStoreBase._error', context: context);
|
||||
late final _$_errorAtom = Atom(
|
||||
name: 'CategoriesStoreBase._error',
|
||||
context: context,
|
||||
);
|
||||
|
||||
String? get error {
|
||||
_$_errorAtom.reportRead();
|
||||
|
|
@ -70,21 +77,26 @@ mixin _$CategoriesStore on CategoriesStoreBase, Store {
|
|||
});
|
||||
}
|
||||
|
||||
late final _$getCategoriesAsyncAction =
|
||||
AsyncAction('CategoriesStoreBase.getCategories', context: context);
|
||||
late final _$getCategoriesAsyncAction = AsyncAction(
|
||||
'CategoriesStoreBase.getCategories',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> getCategories() {
|
||||
return _$getCategoriesAsyncAction.run(() => super.getCategories());
|
||||
}
|
||||
|
||||
late final _$CategoriesStoreBaseActionController =
|
||||
ActionController(name: 'CategoriesStoreBase', context: context);
|
||||
late final _$CategoriesStoreBaseActionController = ActionController(
|
||||
name: 'CategoriesStoreBase',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> refreshCategories() {
|
||||
final _$actionInfo = _$CategoriesStoreBaseActionController.startAction(
|
||||
name: 'CategoriesStoreBase.refreshCategories');
|
||||
name: 'CategoriesStoreBase.refreshCategories',
|
||||
);
|
||||
try {
|
||||
return super.refreshCategories();
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:frosty/models/category.dart';
|
||||
import 'package:frosty/screens/home/top/categories/category_streams.dart';
|
||||
import 'package:frosty/widgets/cached_image.dart';
|
||||
import 'package:frosty/widgets/loading_indicator.dart';
|
||||
import 'package:frosty/widgets/frosty_cached_network_image.dart';
|
||||
import 'package:frosty/widgets/skeleton_loader.dart';
|
||||
|
||||
/// A tappable card widget that displays a category's box art and name under.
|
||||
class CategoryCard extends StatelessWidget {
|
||||
|
|
@ -28,15 +28,19 @@ class CategoryCard extends StatelessWidget {
|
|||
? () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CategoryStreams(
|
||||
categoryId: category.id,
|
||||
),
|
||||
builder: (context) => CategoryStreams(categoryId: category.id),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: EdgeInsets.only(
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
left: 16 + MediaQuery.of(context).padding.left,
|
||||
right: 16 + MediaQuery.of(context).padding.right,
|
||||
),
|
||||
child: Row(
|
||||
spacing: 16,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
|
|
@ -50,22 +54,19 @@ class CategoryCard extends StatelessWidget {
|
|||
null,
|
||||
'${artWidth}x$artHeight.jpg',
|
||||
),
|
||||
placeholder: (context, url) => ColoredBox(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: const LoadingIndicator(),
|
||||
placeholder: (context, url) => const SkeletonLoader(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Flexible(
|
||||
child: Text(
|
||||
category.name,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,32 +1,216 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
import 'package:frosty/apis/twitch_api.dart';
|
||||
import 'package:frosty/screens/home/stream_list/stream_list_store.dart';
|
||||
import 'package:frosty/screens/home/stream_list/streams_list.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
import 'package:frosty/widgets/blurred_container.dart';
|
||||
import 'package:frosty/widgets/frosty_cached_network_image.dart';
|
||||
import 'package:frosty/widgets/skeleton_loader.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// A widget that displays a list of streams under the provided [categoryId].
|
||||
class CategoryStreams extends StatelessWidget {
|
||||
class CategoryStreams extends StatefulWidget {
|
||||
/// The category id, used for fetching the relevant streams in the [ListStore].
|
||||
final String categoryId;
|
||||
|
||||
const CategoryStreams({
|
||||
super.key,
|
||||
required this.categoryId,
|
||||
});
|
||||
const CategoryStreams({super.key, required this.categoryId});
|
||||
|
||||
@override
|
||||
State<CategoryStreams> createState() => _CategoryStreamsState();
|
||||
}
|
||||
|
||||
class _CategoryStreamsState extends State<CategoryStreams> {
|
||||
late final _listStore = ListStore(
|
||||
listType: ListType.category,
|
||||
categoryId: widget.categoryId,
|
||||
authStore: context.read<AuthStore>(),
|
||||
twitchApi: context.read<TwitchApi>(),
|
||||
settingsStore: context.read<SettingsStore>(),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
backgroundColor: theme.scaffoldBackgroundColor,
|
||||
extendBody: true,
|
||||
extendBodyBehindAppBar: true,
|
||||
body: Stack(
|
||||
children: [
|
||||
// Stream list content behind the pinned elements
|
||||
Positioned.fill(
|
||||
child: StreamsList(
|
||||
listType: ListType.category,
|
||||
categoryId: widget.categoryId,
|
||||
showJumpButton: true,
|
||||
),
|
||||
),
|
||||
// Single blurred background spanning app bar and category card
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: BlurredContainer(
|
||||
gradientDirection: GradientDirection.up,
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top,
|
||||
left: MediaQuery.of(context).padding.left,
|
||||
right: MediaQuery.of(context).padding.right,
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.colorScheme.outlineVariant.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// App bar section
|
||||
SizedBox(
|
||||
height: kToolbarHeight,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Back',
|
||||
icon: Icon(Icons.adaptive.arrow_back_rounded),
|
||||
onPressed: Navigator.of(context).pop,
|
||||
),
|
||||
shape: const Border(),
|
||||
],
|
||||
),
|
||||
body: StreamsList(
|
||||
listType: ListType.category,
|
||||
categoryId: categoryId,
|
||||
showJumpButton: true,
|
||||
),
|
||||
// Category card section
|
||||
Observer(
|
||||
builder: (_) {
|
||||
if (_listStore.categoryDetails != null) {
|
||||
return _TransparentCategoryCard(
|
||||
category: _listStore.categoryDetails!,
|
||||
);
|
||||
} else {
|
||||
// Skeleton loader for category card
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
),
|
||||
child: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 80,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 3 / 4,
|
||||
child: SkeletonLoader(
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
SkeletonLoader(
|
||||
height: 20,
|
||||
width: double.infinity,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
SkeletonLoader(
|
||||
height: 16,
|
||||
width: 120,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_listStore.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// A transparent version of CategoryCard for use in blurred overlays
|
||||
class _TransparentCategoryCard extends StatelessWidget {
|
||||
final dynamic category;
|
||||
|
||||
const _TransparentCategoryCard({required this.category});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Calculate the dimensions of the box art based on the current dimensions of the screen.
|
||||
final size = MediaQuery.of(context).size;
|
||||
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||
final artWidth = (size.width * pixelRatio) ~/ 5;
|
||||
final artHeight = (artWidth * (4 / 3)).toInt();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16, left: 16, right: 16),
|
||||
child: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 3 / 4,
|
||||
child: FrostyCachedNetworkImage(
|
||||
imageUrl: category.boxArtUrl.replaceRange(
|
||||
category.boxArtUrl.lastIndexOf('-') + 1,
|
||||
null,
|
||||
'${artWidth}x$artHeight.jpg',
|
||||
),
|
||||
placeholder: (context, url) => const SkeletonLoader(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
category.name,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,14 +3,12 @@ import 'package:frosty/screens/home/home_store.dart';
|
|||
import 'package:frosty/screens/home/stream_list/stream_list_store.dart';
|
||||
import 'package:frosty/screens/home/stream_list/streams_list.dart';
|
||||
import 'package:frosty/screens/home/top/categories/categories.dart';
|
||||
import 'package:frosty/widgets/blurred_container.dart';
|
||||
|
||||
class TopSection extends StatefulWidget {
|
||||
final HomeStore homeStore;
|
||||
|
||||
const TopSection({
|
||||
super.key,
|
||||
required this.homeStore,
|
||||
});
|
||||
const TopSection({super.key, required this.homeStore});
|
||||
|
||||
@override
|
||||
State<TopSection> createState() => _TopSectionState();
|
||||
|
|
@ -30,20 +28,9 @@ class _TopSectionState extends State<TopSection>
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
return Stack(
|
||||
children: [
|
||||
TabBar(
|
||||
isScrollable: true,
|
||||
controller: _tabBarController,
|
||||
tabs: const [
|
||||
Tab(
|
||||
child: Text('Streams'),
|
||||
),
|
||||
Tab(child: Text('Categories')),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
Positioned.fill(
|
||||
child: TabBarView(
|
||||
controller: _tabBarController,
|
||||
children: [
|
||||
|
|
@ -59,6 +46,27 @@ class _TopSectionState extends State<TopSection>
|
|||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: BlurredContainer(
|
||||
gradientDirection: GradientDirection.up,
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top,
|
||||
left: MediaQuery.of(context).padding.left,
|
||||
right: MediaQuery.of(context).padding.right,
|
||||
),
|
||||
child: TabBar(
|
||||
isScrollable: true,
|
||||
controller: _tabBarController,
|
||||
tabs: const [
|
||||
Tab(child: Text('Streams')),
|
||||
Tab(child: Text('Categories')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
51
lib/screens/onboarding/login_webview.dart
Normal file
51
lib/screens/onboarding/login_webview.dart
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/widgets/frosty_app_bar.dart';
|
||||
import 'package:frosty/widgets/frosty_dialog.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
/// Reusable WebView widget for Twitch OAuth login flow
|
||||
class LoginWebView extends StatelessWidget {
|
||||
/// Optional widget to navigate to after successful login
|
||||
final Widget? routeAfter;
|
||||
|
||||
const LoginWebView({super.key, this.routeAfter});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authStore = context.read<AuthStore>();
|
||||
|
||||
return Scaffold(
|
||||
appBar: FrostyAppBar(
|
||||
title: const Text('Connect with Twitch'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.help_rounded),
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return FrostyDialog(
|
||||
title: 'Workaround for the Twitch cookie banner',
|
||||
message:
|
||||
'If the Twitch cookie banner is still blocking the login, try clicking one of the links in the cookie policy description and navigating until you reach the Twitch home page. From there, you can try logging in on the top right profile icon. Once logged in, go back to the first step of the onboarding and then try again.',
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: WebViewWidget(
|
||||
controller: authStore.createAuthWebViewController(
|
||||
routeAfter: routeAfter,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +1,14 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:frosty/screens/onboarding/login_webview.dart';
|
||||
import 'package:frosty/screens/onboarding/onboarding_scaffold.dart';
|
||||
import 'package:frosty/screens/onboarding/onboarding_setup.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/widgets/app_bar.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:simple_icons/simple_icons.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
class OnboardingLogin extends StatelessWidget {
|
||||
const OnboardingLogin({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authStore = context.read<AuthStore>();
|
||||
|
||||
return OnboardingScaffold(
|
||||
header: 'Log in',
|
||||
subtitle:
|
||||
|
|
@ -23,39 +18,7 @@ class OnboardingLogin extends StatelessWidget {
|
|||
buttonText: 'Connect with Twitch',
|
||||
buttonIcon: const Icon(SimpleIcons.twitch),
|
||||
skipRoute: const OnboardingSetup(),
|
||||
route: Scaffold(
|
||||
appBar: FrostyAppBar(
|
||||
title: const Text('Connect with Twitch'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.help_rounded),
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title:
|
||||
const Text('Workaround for the Twitch cookie banner'),
|
||||
content: const Text(
|
||||
'If the Twitch cookie banner is still blocking the login, try clicking one of the links in the cookie policy description and navigating until you reach the Twitch home page. From there, you can try logging in on the top right profile icon. Once logged in, go back to the first step of the onboarding and then try again.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: WebViewWidget(
|
||||
controller: authStore.createAuthWebViewController(
|
||||
routeAfter: const OnboardingSetup(),
|
||||
),
|
||||
),
|
||||
),
|
||||
route: LoginWebView(routeAfter: const OnboardingSetup()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,14 +37,13 @@ class OnboardingScaffold extends StatelessWidget {
|
|||
]);
|
||||
|
||||
if (isLast) {
|
||||
SharedPreferences.getInstance()
|
||||
.then((prefs) => prefs.setBool('first_run', false));
|
||||
SharedPreferences.getInstance().then(
|
||||
(prefs) => prefs.setBool('first_run', false),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
shape: const Border(),
|
||||
),
|
||||
appBar: AppBar(shape: const Border()),
|
||||
body: SafeArea(
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
|
|
@ -53,16 +52,14 @@ class OnboardingScaffold extends StatelessWidget {
|
|||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 50, 20, 20),
|
||||
child: Column(
|
||||
spacing: 24,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
spacing: 12,
|
||||
children: [
|
||||
if (showLogo) ...[
|
||||
SvgPicture.asset(
|
||||
'assets/icons/logo.svg',
|
||||
height: 80,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SvgPicture.asset('assets/icons/logo.svg', height: 80),
|
||||
],
|
||||
Text(
|
||||
header,
|
||||
|
|
@ -74,7 +71,6 @@ class OnboardingScaffold extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
Opacity(
|
||||
opacity: 0.8,
|
||||
child: Text(
|
||||
|
|
@ -97,8 +93,10 @@ class OnboardingScaffold extends StatelessWidget {
|
|||
),
|
||||
if (disclaimer != null)
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Opacity(
|
||||
opacity: 0.5,
|
||||
child: Text(
|
||||
|
|
@ -120,9 +118,7 @@ class OnboardingScaffold extends StatelessWidget {
|
|||
)
|
||||
: Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => route,
|
||||
),
|
||||
MaterialPageRoute(builder: (context) => route),
|
||||
),
|
||||
icon: buttonIcon ?? const SizedBox(),
|
||||
label: Text(buttonText ?? 'Next'),
|
||||
|
|
@ -135,9 +131,7 @@ class OnboardingScaffold extends StatelessWidget {
|
|||
child: TextButton(
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => skipRoute!,
|
||||
),
|
||||
MaterialPageRoute(builder: (context) => skipRoute!),
|
||||
),
|
||||
child: const Text('Skip'),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@ import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
|||
import 'package:firebase_performance/firebase_performance.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
|
||||
// import removed: flutter_colorpicker
|
||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
import 'package:frosty/screens/onboarding/onboarding_scaffold.dart';
|
||||
import 'package:frosty/screens/onboarding/onboarding_welcome.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
import 'package:frosty/screens/settings/widgets/settings_list_select.dart';
|
||||
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
|
||||
import 'package:frosty/widgets/dialog.dart';
|
||||
import 'package:frosty/widgets/accent_color_setting.dart';
|
||||
import 'package:frosty/widgets/theme_selection_setting.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
|
|
@ -29,60 +29,8 @@ class OnboardingSetup extends StatelessWidget {
|
|||
builder: (context) {
|
||||
return ListView(
|
||||
children: [
|
||||
SettingsListSelect(
|
||||
title: 'Theme',
|
||||
selectedOption: themeNames[settingsStore.themeType.index],
|
||||
options: themeNames,
|
||||
onChanged: (newTheme) => settingsStore.themeType =
|
||||
ThemeType.values[themeNames.indexOf(newTheme)],
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Accent color'),
|
||||
trailing: IconButton(
|
||||
icon: DecoratedBox(
|
||||
position: DecorationPosition.foreground,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: CircleAvatar(
|
||||
backgroundColor: Color(settingsStore.accentColor),
|
||||
radius: 16,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => FrostyDialog(
|
||||
title: 'Accent color',
|
||||
content: SingleChildScrollView(
|
||||
child: ColorPicker(
|
||||
pickerColor: Color(settingsStore.accentColor),
|
||||
onColorChanged: (newColor) =>
|
||||
// TODO: Update when new method arrives in stable:
|
||||
// https://github.com/flutter/flutter/issues/160184#issuecomment-2560184639
|
||||
// ignore: deprecated_member_use
|
||||
settingsStore.accentColor = newColor.value,
|
||||
enableAlpha: false,
|
||||
pickerAreaBorderRadius:
|
||||
const BorderRadius.all(Radius.circular(8)),
|
||||
labelTypes: const [],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Done'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
ThemeSelectionSetting(settingsStore: settingsStore),
|
||||
AccentColorSetting(settingsStore: settingsStore),
|
||||
SettingsListSwitch(
|
||||
title: 'Show historical recent messages',
|
||||
subtitle: Text.rich(
|
||||
|
|
@ -99,9 +47,7 @@ class OnboardingSetup extends StatelessWidget {
|
|||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () => launchUrl(
|
||||
Uri.parse(
|
||||
'https://recent-messages.robotty.de/',
|
||||
),
|
||||
Uri.parse('https://recent-messages.robotty.de/'),
|
||||
mode: settingsStore.launchUrlExternal
|
||||
? LaunchMode.externalApplication
|
||||
: LaunchMode.inAppBrowserView,
|
||||
|
|
@ -123,12 +69,15 @@ class OnboardingSetup extends StatelessWidget {
|
|||
onChanged: (newValue) {
|
||||
settingsStore.shareCrashLogsAndAnalytics = newValue;
|
||||
|
||||
FirebaseCrashlytics.instance
|
||||
.setCrashlyticsCollectionEnabled(newValue);
|
||||
FirebaseAnalytics.instance
|
||||
.setAnalyticsCollectionEnabled(newValue);
|
||||
FirebasePerformance.instance
|
||||
.setPerformanceCollectionEnabled(newValue);
|
||||
FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(
|
||||
newValue,
|
||||
);
|
||||
FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(
|
||||
newValue,
|
||||
);
|
||||
FirebasePerformance.instance.setPerformanceCollectionEnabled(
|
||||
newValue,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import 'package:frosty/screens/channel/channel.dart';
|
|||
import 'package:frosty/screens/settings/account/blocked_users.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/screens/settings/widgets/settings_tile_route.dart';
|
||||
import 'package:frosty/widgets/frosty_dialog.dart';
|
||||
import 'package:frosty/widgets/frosty_scrollbar.dart';
|
||||
|
||||
class AccountOptions extends StatelessWidget {
|
||||
final AuthStore authStore;
|
||||
|
|
@ -12,21 +14,21 @@ class AccountOptions extends StatelessWidget {
|
|||
Future<void> _showLogoutDialog(BuildContext context) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog.adaptive(
|
||||
title: const Text('Log out'),
|
||||
content: const Text('Are you sure you want to log out?'),
|
||||
builder: (context) => FrostyDialog(
|
||||
title: 'Log out',
|
||||
message: 'Are you sure you want to log out?',
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
authStore.logout();
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Yes'),
|
||||
child: const Text('Log out'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -35,7 +37,8 @@ class AccountOptions extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
return FrostyScrollbar(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
primary: false,
|
||||
children: [
|
||||
|
|
@ -52,9 +55,7 @@ class AccountOptions extends StatelessWidget {
|
|||
SettingsTileRoute(
|
||||
leading: const Icon(Icons.block_rounded),
|
||||
title: 'Blocked users',
|
||||
child: BlockedUsers(
|
||||
authStore: authStore,
|
||||
),
|
||||
child: BlockedUsers(authStore: authStore),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.logout_rounded),
|
||||
|
|
@ -62,6 +63,7 @@ class AccountOptions extends StatelessWidget {
|
|||
onTap: () => _showLogoutDialog(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,36 +4,36 @@ import 'package:flutter_mobx/flutter_mobx.dart';
|
|||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/utils.dart';
|
||||
import 'package:frosty/widgets/alert_message.dart';
|
||||
import 'package:frosty/widgets/settings_page_layout.dart';
|
||||
|
||||
class BlockedUsers extends StatelessWidget {
|
||||
final AuthStore authStore;
|
||||
|
||||
const BlockedUsers({
|
||||
super.key,
|
||||
required this.authStore,
|
||||
});
|
||||
const BlockedUsers({super.key, required this.authStore});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Observer(
|
||||
builder: (context) {
|
||||
if (authStore.user.blockedUsers.isEmpty) {
|
||||
return RefreshIndicator.adaptive(
|
||||
onRefresh: () async {
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
await authStore.user
|
||||
.refreshBlockedUsers(headers: authStore.headersTwitch);
|
||||
await authStore.user.refreshBlockedUsers();
|
||||
},
|
||||
child: Observer(
|
||||
builder: (context) {
|
||||
if (authStore.user.blockedUsers.isEmpty) {
|
||||
return const Center(
|
||||
child: AlertMessage(
|
||||
message: 'No blocked users',
|
||||
child: const Center(
|
||||
child: AlertMessage(message: 'No blocked users', vertical: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView(
|
||||
children: authStore.user.blockedUsers.map(
|
||||
(blockedUser) {
|
||||
|
||||
return SettingsPageLayout(
|
||||
hasBottomPadding: false,
|
||||
onRefresh: () async {
|
||||
HapticFeedback.lightImpact();
|
||||
await authStore.user.refreshBlockedUsers();
|
||||
},
|
||||
children: authStore.user.blockedUsers.map((blockedUser) {
|
||||
final displayName = getReadableName(
|
||||
blockedUser.displayName,
|
||||
blockedUser.userLogin,
|
||||
|
|
@ -42,9 +42,7 @@ class BlockedUsers extends StatelessWidget {
|
|||
return ListTile(
|
||||
title: Text(displayName),
|
||||
trailing: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.red,
|
||||
),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
onPressed: () => authStore.showBlockDialog(
|
||||
context,
|
||||
targetUser: displayName,
|
||||
|
|
@ -53,11 +51,9 @@ class BlockedUsers extends StatelessWidget {
|
|||
child: const Text('Unblock'),
|
||||
),
|
||||
);
|
||||
},
|
||||
).toList(),
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
import 'package:frosty/screens/onboarding/login_webview.dart';
|
||||
import 'package:frosty/screens/settings/account/account_options.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/widgets/app_bar.dart';
|
||||
import 'package:frosty/utils/modal_bottom_sheet.dart';
|
||||
import 'package:frosty/widgets/profile_picture.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
class ProfileCard extends StatelessWidget {
|
||||
final AuthStore authStore;
|
||||
|
|
@ -13,7 +13,7 @@ class ProfileCard extends StatelessWidget {
|
|||
const ProfileCard({super.key, required this.authStore});
|
||||
|
||||
Future<void> _showAccountOptionsModalBottomSheet(BuildContext context) {
|
||||
return showModalBottomSheet(
|
||||
return showModalBottomSheetWithProperFocus(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AccountOptions(authStore: authStore);
|
||||
|
|
@ -31,7 +31,7 @@ class ProfileCard extends StatelessWidget {
|
|||
Icons.error_outline_rounded,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
title: const Text('Failed to connect'),
|
||||
title: const Text('Unable to connect to Twitch'),
|
||||
trailing: FilledButton.tonal(
|
||||
onPressed: authStore.init,
|
||||
child: const Text('Reconnect'),
|
||||
|
|
@ -61,46 +61,12 @@ class ProfileCard extends StatelessWidget {
|
|||
),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) {
|
||||
return Scaffold(
|
||||
appBar: FrostyAppBar(
|
||||
title: const Text('Connect with Twitch'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.help_rounded),
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text(
|
||||
'Workaround for the Twitch cookie banner',
|
||||
),
|
||||
content: const Text(
|
||||
'If the Twitch cookie banner is still blocking the login, try clicking one of the links in the cookie policy description and navigating until you reach the Twitch home page. From there, you can try logging in on the top right profile icon. Once logged in, exit this page and then try again.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: WebViewWidget(
|
||||
controller: authStore.createAuthWebViewController(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
MaterialPageRoute(builder: (context) => LoginWebView()),
|
||||
),
|
||||
onLongPress: () async {
|
||||
final clipboardText =
|
||||
(await Clipboard.getData(Clipboard.kTextPlain))?.text;
|
||||
final clipboardText = (await Clipboard.getData(
|
||||
Clipboard.kTextPlain,
|
||||
))?.text;
|
||||
|
||||
if (clipboardText == null) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@ import 'package:frosty/screens/settings/widgets/settings_list_select.dart';
|
|||
import 'package:frosty/screens/settings/widgets/settings_list_slider.dart';
|
||||
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
|
||||
import 'package:frosty/screens/settings/widgets/settings_muted_words.dart';
|
||||
import 'package:frosty/widgets/cached_image.dart';
|
||||
import 'package:frosty/utils/context_extensions.dart';
|
||||
import 'package:frosty/widgets/frosty_cached_network_image.dart';
|
||||
import 'package:frosty/widgets/section_header.dart';
|
||||
import 'package:frosty/widgets/settings_page_layout.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class ChatSettings extends StatefulWidget {
|
||||
|
|
@ -30,25 +32,19 @@ class _ChatSettingsState extends State<ChatSettings> {
|
|||
final settingsStore = widget.settingsStore;
|
||||
|
||||
return Observer(
|
||||
builder: (context) => ListView(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
builder: (context) => SettingsPageLayout(
|
||||
children: [
|
||||
const SectionHeader(
|
||||
'Message sizing',
|
||||
isFirst: true,
|
||||
),
|
||||
const SectionHeader('Message sizing', isFirst: true),
|
||||
ExpansionTile(
|
||||
title: const Text(
|
||||
'Preview',
|
||||
),
|
||||
title: const Text('Preview'),
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: DefaultTextStyle(
|
||||
style: DefaultTextStyle.of(context)
|
||||
.style
|
||||
.copyWith(fontSize: settingsStore.fontSize),
|
||||
style: DefaultTextStyle.of(
|
||||
context,
|
||||
).style.copyWith(fontSize: settingsStore.fontSize),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
|
@ -80,20 +76,17 @@ class _ChatSettingsState extends State<ChatSettings> {
|
|||
),
|
||||
],
|
||||
),
|
||||
textScaler:
|
||||
TextScaler.linear(settingsStore.messageScale),
|
||||
textScaler: settingsStore.messageScale.textScaler,
|
||||
),
|
||||
SizedBox(height: settingsStore.messageSpacing),
|
||||
Text(
|
||||
'Hello! Here\'s a text preview.',
|
||||
textScaler:
|
||||
TextScaler.linear(settingsStore.messageScale),
|
||||
textScaler: settingsStore.messageScale.textScaler,
|
||||
),
|
||||
SizedBox(height: settingsStore.messageSpacing),
|
||||
Text(
|
||||
'And another for spacing without an emote!',
|
||||
textScaler:
|
||||
TextScaler.linear(settingsStore.messageScale),
|
||||
textScaler: settingsStore.messageScale.textScaler,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -146,14 +139,6 @@ class _ChatSettingsState extends State<ChatSettings> {
|
|||
onChanged: (newValue) => settingsStore.fontSize = newValue,
|
||||
),
|
||||
const SectionHeader('Message appearance'),
|
||||
SettingsListSwitch(
|
||||
title: 'Use readable name colors',
|
||||
subtitle: const Text(
|
||||
'Adjusts the lightness value of overly bright and dark names.',
|
||||
),
|
||||
value: settingsStore.useReadableColors,
|
||||
onChanged: (newValue) => settingsStore.useReadableColors = newValue,
|
||||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Show deleted messages',
|
||||
subtitle: const Text(
|
||||
|
|
@ -178,7 +163,7 @@ class _ChatSettingsState extends State<ChatSettings> {
|
|||
),
|
||||
const SectionHeader('Delay and latency'),
|
||||
SettingsListSwitch(
|
||||
title: 'Sync message delay with stream latency (experimental)',
|
||||
title: 'Sync message delay and stream latency',
|
||||
value: settingsStore.autoSyncChatDelay,
|
||||
onChanged: (newValue) => settingsStore.autoSyncChatDelay = newValue,
|
||||
),
|
||||
|
|
@ -209,11 +194,6 @@ class _ChatSettingsState extends State<ChatSettings> {
|
|||
onChanged: (newValue) => settingsStore.showUserNotices = newValue,
|
||||
),
|
||||
const SectionHeader('Layout'),
|
||||
SettingsListSwitch(
|
||||
title: 'Show bottom bar',
|
||||
value: settingsStore.showBottomBar,
|
||||
onChanged: (newValue) => settingsStore.showBottomBar = newValue,
|
||||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Move emote menu button left',
|
||||
subtitle: const Text(
|
||||
|
|
@ -224,10 +204,17 @@ class _ChatSettingsState extends State<ChatSettings> {
|
|||
settingsStore.emoteMenuButtonOnLeft = newValue,
|
||||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Move notifications to bottom',
|
||||
value: settingsStore.chatNotificationsOnBottom,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.chatNotificationsOnBottom = newValue,
|
||||
title: 'Persist chat tabs',
|
||||
subtitle: const Text(
|
||||
'Secondary chat tabs are remembered when switching channels.',
|
||||
),
|
||||
value: settingsStore.persistChatTabs,
|
||||
onChanged: (newValue) {
|
||||
settingsStore.persistChatTabs = newValue;
|
||||
if (!newValue) {
|
||||
settingsStore.secondaryTabs = [];
|
||||
}
|
||||
},
|
||||
),
|
||||
const SectionHeader('Landscape mode'),
|
||||
SettingsListSwitch(
|
||||
|
|
@ -238,8 +225,9 @@ class _ChatSettingsState extends State<ChatSettings> {
|
|||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Force vertical chat',
|
||||
subtitle:
|
||||
const Text('Intended for tablets and other larger displays.'),
|
||||
subtitle: const Text(
|
||||
'Intended for tablets and other larger displays.',
|
||||
),
|
||||
value: settingsStore.landscapeForceVerticalChat,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.landscapeForceVerticalChat = newValue,
|
||||
|
|
@ -252,17 +240,9 @@ class _ChatSettingsState extends State<ChatSettings> {
|
|||
landscapeCutoutNames[settingsStore.landscapeCutout.index],
|
||||
options: landscapeCutoutNames,
|
||||
onChanged: (newValue) => settingsStore.landscapeCutout =
|
||||
LandscapeCutoutType
|
||||
.values[landscapeCutoutNames.indexOf(newValue)],
|
||||
),
|
||||
SettingsListSlider(
|
||||
title: 'Chat width',
|
||||
trailing: '${(settingsStore.chatWidth * 100).toStringAsFixed(0)}%',
|
||||
value: settingsStore.chatWidth,
|
||||
min: 0.2,
|
||||
max: 0.6,
|
||||
divisions: 8,
|
||||
onChanged: (newValue) => settingsStore.chatWidth = newValue,
|
||||
LandscapeCutoutType.values[landscapeCutoutNames.indexOf(
|
||||
newValue,
|
||||
)],
|
||||
),
|
||||
SettingsListSlider(
|
||||
title: 'Chat overlay opacity',
|
||||
|
|
@ -275,16 +255,6 @@ class _ChatSettingsState extends State<ChatSettings> {
|
|||
onChanged: (newValue) =>
|
||||
settingsStore.fullScreenChatOverlayOpacity = newValue,
|
||||
),
|
||||
const SectionHeader('Sleep'),
|
||||
SettingsListSwitch(
|
||||
title: 'Prevent sleep in chat-only mode',
|
||||
subtitle: const Text(
|
||||
'Requires restarting the chat in order to take effect.',
|
||||
),
|
||||
value: settingsStore.chatOnlyPreventSleep,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.chatOnlyPreventSleep = newValue,
|
||||
),
|
||||
const SectionHeader('Muted keywords'),
|
||||
SettingsMutedWords(settingsStore: settingsStore),
|
||||
SettingsListSwitch(
|
||||
|
|
@ -357,9 +327,7 @@ class _ChatSettingsState extends State<ChatSettings> {
|
|||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () => launchUrl(
|
||||
Uri.parse(
|
||||
'https://recent-messages.robotty.de/',
|
||||
),
|
||||
Uri.parse('https://recent-messages.robotty.de/'),
|
||||
mode: settingsStore.launchUrlExternal
|
||||
? LaunchMode.externalApplication
|
||||
: LaunchMode.inAppBrowserView,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
|
||||
// import removed: flutter_colorpicker
|
||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
import 'package:frosty/screens/settings/widgets/settings_list_select.dart';
|
||||
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
|
||||
import 'package:frosty/widgets/dialog.dart';
|
||||
import 'package:frosty/widgets/accent_color_setting.dart';
|
||||
import 'package:frosty/widgets/external_browser_setting.dart';
|
||||
// import removed: frosty/widgets/dialog.dart
|
||||
import 'package:frosty/widgets/section_header.dart';
|
||||
import 'package:frosty/widgets/settings_page_layout.dart';
|
||||
import 'package:frosty/widgets/theme_selection_setting.dart';
|
||||
|
||||
class GeneralSettings extends StatelessWidget {
|
||||
final SettingsStore settingsStore;
|
||||
|
|
@ -15,66 +18,11 @@ class GeneralSettings extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Observer(
|
||||
builder: (context) => ListView(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
builder: (context) => SettingsPageLayout(
|
||||
children: [
|
||||
const SectionHeader(
|
||||
'Theme',
|
||||
isFirst: true,
|
||||
),
|
||||
SettingsListSelect(
|
||||
selectedOption: themeNames[settingsStore.themeType.index],
|
||||
options: themeNames,
|
||||
onChanged: (newTheme) => settingsStore.themeType =
|
||||
ThemeType.values[themeNames.indexOf(newTheme)],
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Accent color'),
|
||||
trailing: IconButton(
|
||||
icon: DecoratedBox(
|
||||
position: DecorationPosition.foreground,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: CircleAvatar(
|
||||
backgroundColor: Color(settingsStore.accentColor),
|
||||
radius: 16,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => FrostyDialog(
|
||||
title: 'Accent color',
|
||||
content: SingleChildScrollView(
|
||||
child: ColorPicker(
|
||||
pickerColor: Color(settingsStore.accentColor),
|
||||
onColorChanged: (newColor) =>
|
||||
// TODO: Update when new method arrives in stable:
|
||||
// https://github.com/flutter/flutter/issues/160184#issuecomment-2560184639
|
||||
// ignore: deprecated_member_use
|
||||
settingsStore.accentColor = newColor.value,
|
||||
enableAlpha: false,
|
||||
pickerAreaBorderRadius:
|
||||
const BorderRadius.all(Radius.circular(8)),
|
||||
labelTypes: const [],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Done'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SectionHeader('Theme', isFirst: true),
|
||||
ThemeSelectionSetting(settingsStore: settingsStore),
|
||||
AccentColorSetting(settingsStore: settingsStore),
|
||||
const SectionHeader('Stream card'),
|
||||
SettingsListSwitch(
|
||||
title: 'Use large stream card',
|
||||
|
|
@ -87,11 +35,7 @@ class GeneralSettings extends StatelessWidget {
|
|||
onChanged: (newValue) => settingsStore.showThumbnails = newValue,
|
||||
),
|
||||
const SectionHeader('Links'),
|
||||
SettingsListSwitch(
|
||||
title: 'Open links in external browser',
|
||||
value: settingsStore.launchUrlExternal,
|
||||
onChanged: (newValue) => settingsStore.launchUrlExternal = newValue,
|
||||
),
|
||||
ExternalBrowserSetting(settingsStore: settingsStore),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,16 +10,14 @@ import 'package:frosty/screens/settings/stores/settings_store.dart';
|
|||
import 'package:frosty/screens/settings/widgets/release_notes.dart';
|
||||
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
|
||||
import 'package:frosty/widgets/alert_message.dart';
|
||||
import 'package:frosty/widgets/frosty_dialog.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class OtherSettings extends StatefulWidget {
|
||||
final SettingsStore settingsStore;
|
||||
|
||||
const OtherSettings({
|
||||
super.key,
|
||||
required this.settingsStore,
|
||||
});
|
||||
const OtherSettings({super.key, required this.settingsStore});
|
||||
|
||||
@override
|
||||
State<OtherSettings> createState() => _OtherSettingsState();
|
||||
|
|
@ -29,15 +27,15 @@ class _OtherSettingsState extends State<OtherSettings> {
|
|||
Future<void> _showConfirmDialog(BuildContext context) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog.adaptive(
|
||||
title: const Text('Reset all settings'),
|
||||
content: const Text('Are you sure you want to reset all settings?'),
|
||||
builder: (context) => FrostyDialog(
|
||||
title: 'Reset all settings',
|
||||
message: 'Are you sure you want to reset all settings?',
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.heavyImpact();
|
||||
|
||||
|
|
@ -54,7 +52,7 @@ class _OtherSettingsState extends State<OtherSettings> {
|
|||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Yes'),
|
||||
child: const Text('Reset'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -89,11 +87,9 @@ class _OtherSettingsState extends State<OtherSettings> {
|
|||
ListTile(
|
||||
leading: const Icon(Icons.notes_rounded),
|
||||
title: const Text('Release notes'),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const ReleaseNotes(),
|
||||
),
|
||||
),
|
||||
onTap: () => Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (context) => const ReleaseNotes())),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.launch_rounded),
|
||||
|
|
@ -141,12 +137,15 @@ class _OtherSettingsState extends State<OtherSettings> {
|
|||
onChanged: (newValue) {
|
||||
widget.settingsStore.shareCrashLogsAndAnalytics = newValue;
|
||||
|
||||
FirebaseCrashlytics.instance
|
||||
.setCrashlyticsCollectionEnabled(newValue);
|
||||
FirebaseAnalytics.instance
|
||||
.setAnalyticsCollectionEnabled(newValue);
|
||||
FirebasePerformance.instance
|
||||
.setPerformanceCollectionEnabled(newValue);
|
||||
FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(
|
||||
newValue,
|
||||
);
|
||||
FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(
|
||||
newValue,
|
||||
);
|
||||
FirebasePerformance.instance.setPerformanceCollectionEnabled(
|
||||
newValue,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:frosty/screens/settings/account/widgets/profile_card.dart';
|
||||
import 'package:frosty/screens/settings/chat_settings.dart';
|
||||
import 'package:frosty/screens/settings/general_settings.dart';
|
||||
|
|
@ -9,7 +10,8 @@ import 'package:frosty/screens/settings/stores/auth_store.dart';
|
|||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
import 'package:frosty/screens/settings/video_settings.dart';
|
||||
import 'package:frosty/screens/settings/widgets/settings_tile_route.dart';
|
||||
import 'package:frosty/widgets/app_bar.dart';
|
||||
import 'package:frosty/widgets/blurred_container.dart';
|
||||
import 'package:frosty/widgets/frosty_scrollbar.dart';
|
||||
import 'package:frosty/widgets/section_header.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:simple_icons/simple_icons.dart';
|
||||
|
|
@ -22,8 +24,28 @@ class Settings extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: FrostyAppBar(
|
||||
backgroundColor: theme.scaffoldBackgroundColor,
|
||||
extendBody: true,
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: AppBar(
|
||||
centerTitle: false,
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.transparent,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
systemOverlayStyle: SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: theme.brightness == Brightness.dark
|
||||
? Brightness.light
|
||||
: Brightness.dark,
|
||||
),
|
||||
leading: IconButton(
|
||||
tooltip: 'Back',
|
||||
icon: Icon(Icons.adaptive.arrow_back_rounded),
|
||||
onPressed: Navigator.of(context).pop,
|
||||
),
|
||||
title: const Text('Settings'),
|
||||
actions: [
|
||||
if (Platform.isAndroid)
|
||||
|
|
@ -52,15 +74,19 @@ class Settings extends StatelessWidget {
|
|||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
body: Stack(
|
||||
children: [
|
||||
const SectionHeader(
|
||||
'Profile',
|
||||
isFirst: true,
|
||||
// Main scrollable content
|
||||
FrostyScrollbar(
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top + kToolbarHeight,
|
||||
),
|
||||
child: ListView(
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top + kToolbarHeight + 8,
|
||||
),
|
||||
children: [
|
||||
const SectionHeader('Account', isFirst: true),
|
||||
ProfileCard(authStore: context.read<AuthStore>()),
|
||||
const SectionHeader('Customize'),
|
||||
SettingsTileRoute(
|
||||
|
|
@ -83,6 +109,23 @@ class Settings extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
// Blurred app bar overlay
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: BlurredContainer(
|
||||
gradientDirection: GradientDirection.up,
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top,
|
||||
left: MediaQuery.of(context).padding.left,
|
||||
right: MediaQuery.of(context).padding.right,
|
||||
),
|
||||
child: const SizedBox(height: kToolbarHeight),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@ import 'dart:async';
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:frosty/apis/base_api_client.dart';
|
||||
import 'package:frosty/apis/twitch_api.dart';
|
||||
import 'package:frosty/constants.dart';
|
||||
import 'package:frosty/main.dart';
|
||||
import 'package:frosty/screens/settings/stores/user_store.dart';
|
||||
import 'package:frosty/widgets/frosty_dialog.dart';
|
||||
import 'package:mobx/mobx.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
|
|
@ -29,6 +31,15 @@ abstract class AuthBase with Store {
|
|||
/// Whether the token is valid or not.
|
||||
var _tokenIsValid = false;
|
||||
|
||||
/// Timer used to retry authentication when offline or on transient failures.
|
||||
Timer? _reconnectTimer;
|
||||
|
||||
/// Retry count for reconnection attempts.
|
||||
var _reconnectAttempts = 0;
|
||||
|
||||
/// Maximum number of reconnection attempts before giving up.
|
||||
static const _maxReconnectAttempts = 5;
|
||||
|
||||
/// The MobX store containing information relevant to the current user.
|
||||
final UserStore user;
|
||||
|
||||
|
|
@ -42,8 +53,10 @@ abstract class AuthBase with Store {
|
|||
|
||||
/// Authentication headers for Twitch API requests.
|
||||
@computed
|
||||
Map<String, String> get headersTwitch =>
|
||||
{'Authorization': 'Bearer $_token', 'Client-Id': clientId};
|
||||
Map<String, String> get headersTwitch => {
|
||||
'Authorization': 'Bearer $_token',
|
||||
'Client-Id': clientId,
|
||||
};
|
||||
|
||||
/// Error flag that will be non-null and contain an error message if login failed.
|
||||
@readonly
|
||||
|
|
@ -70,8 +83,9 @@ abstract class AuthBase with Store {
|
|||
if (request.url == 'https://www.twitch.tv/?no-reload=true') {
|
||||
if (routeAfter != null) {
|
||||
navigatorKey.currentState?.pop();
|
||||
navigatorKey.currentState
|
||||
?.push(MaterialPageRoute(builder: (context) => routeAfter));
|
||||
navigatorKey.currentState?.push(
|
||||
MaterialPageRoute(builder: (context) => routeAfter),
|
||||
);
|
||||
} else {
|
||||
// Pop twice, once to dismiss the WebView and again to dismiss the Login dialog.
|
||||
navigatorKey.currentState?.pop();
|
||||
|
|
@ -92,8 +106,12 @@ abstract class AuthBase with Store {
|
|||
NavigationDelegate(
|
||||
onNavigationRequest: (request) =>
|
||||
handleNavigation(request: request, routeAfter: routeAfter),
|
||||
onPageFinished: (_) => webViewController.runJavaScript(
|
||||
'''
|
||||
onWebResourceError: (error) {
|
||||
debugPrint('Auth WebView error: ${error.description}');
|
||||
},
|
||||
onPageFinished: (_) async {
|
||||
try {
|
||||
await webViewController.runJavaScript('''
|
||||
{
|
||||
function modifyElement(element) {
|
||||
element.style.maxHeight = '20vh';
|
||||
|
|
@ -118,8 +136,11 @@ abstract class AuthBase with Store {
|
|||
subtree: true
|
||||
});
|
||||
}
|
||||
''',
|
||||
),
|
||||
''');
|
||||
} catch (e) {
|
||||
debugPrint('Auth WebView JavaScript error: $e');
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
..loadRequest(
|
||||
|
|
@ -132,7 +153,7 @@ abstract class AuthBase with Store {
|
|||
'redirect_uri': 'https://twitch.tv/login',
|
||||
'response_type': 'token',
|
||||
'scope':
|
||||
'chat:read chat:edit user:read:follows user:read:blocked_users user:manage:blocked_users',
|
||||
'chat:read chat:edit user:read:follows user:read:blocked_users user:manage:blocked_users user:manage:chat_color',
|
||||
'force_verify': 'true',
|
||||
},
|
||||
),
|
||||
|
|
@ -156,31 +177,24 @@ abstract class AuthBase with Store {
|
|||
|
||||
void onPressed() {
|
||||
if (isBlocked) {
|
||||
user.unblock(targetId: targetUserId, headers: headersTwitch);
|
||||
user.unblock(targetId: targetUserId);
|
||||
} else {
|
||||
user.block(
|
||||
targetId: targetUserId,
|
||||
displayName: targetUser,
|
||||
headers: headersTwitch,
|
||||
);
|
||||
user.block(targetId: targetUserId, displayName: targetUser);
|
||||
}
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog.adaptive(
|
||||
title: Text(title),
|
||||
content: Text(message),
|
||||
builder: (context) => FrostyDialog(
|
||||
title: title,
|
||||
message: message,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: onPressed,
|
||||
child: const Text('Yes'),
|
||||
),
|
||||
FilledButton(onPressed: onPressed, child: const Text('Yes')),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -206,16 +220,26 @@ abstract class AuthBase with Store {
|
|||
await _storage.write(key: _defaultTokenKey, value: _token);
|
||||
}
|
||||
} else {
|
||||
// Validate the existing token.
|
||||
// Validate the existing token. If it fails, start reconnect loop.
|
||||
try {
|
||||
_tokenIsValid = await twitchApi.validateToken(token: _token!);
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('Token validation failed: $e');
|
||||
_isLoggedIn = false;
|
||||
_startReconnectLoop();
|
||||
return;
|
||||
}
|
||||
|
||||
// If the token is invalid, logout.
|
||||
if (!_tokenIsValid) return await logout();
|
||||
|
||||
// Initialize the user store.
|
||||
await user.init(headers: headersTwitch);
|
||||
await user.init();
|
||||
|
||||
if (user.details != null) _isLoggedIn = true;
|
||||
if (user.details != null) {
|
||||
_isLoggedIn = true;
|
||||
_stopReconnectLoop();
|
||||
}
|
||||
}
|
||||
|
||||
_error = null;
|
||||
|
|
@ -240,10 +264,13 @@ abstract class AuthBase with Store {
|
|||
await _storage.write(key: _userTokenKey, value: token);
|
||||
|
||||
// Initialize the user with the new token.
|
||||
await user.init(headers: headersTwitch);
|
||||
await user.init();
|
||||
|
||||
// Set the login status to logged in.
|
||||
if (user.details != null) _isLoggedIn = true;
|
||||
if (user.details != null) {
|
||||
_isLoggedIn = true;
|
||||
_stopReconnectLoop();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Login failed due to $e');
|
||||
}
|
||||
|
|
@ -253,6 +280,7 @@ abstract class AuthBase with Store {
|
|||
@action
|
||||
Future<void> logout() async {
|
||||
try {
|
||||
_stopReconnectLoop();
|
||||
// Delete the existing user token.
|
||||
await _storage.delete(key: _userTokenKey);
|
||||
_token = null;
|
||||
|
|
@ -277,4 +305,49 @@ abstract class AuthBase with Store {
|
|||
debugPrint(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
void _startReconnectLoop() {
|
||||
if (_reconnectTimer != null) return;
|
||||
_reconnectAttempts = 0;
|
||||
_reconnectTimer = Timer.periodic(const Duration(seconds: 10), (_) async {
|
||||
try {
|
||||
_reconnectAttempts++;
|
||||
if (_reconnectAttempts > _maxReconnectAttempts) {
|
||||
await logout();
|
||||
return;
|
||||
}
|
||||
|
||||
final stored = await _storage.read(key: _userTokenKey);
|
||||
if (stored == null) {
|
||||
_stopReconnectLoop();
|
||||
return;
|
||||
}
|
||||
|
||||
final isValid = await twitchApi.validateToken(token: stored);
|
||||
if (!isValid) {
|
||||
await logout();
|
||||
return;
|
||||
}
|
||||
|
||||
// Token valid again — restore session.
|
||||
_token = stored;
|
||||
await user.init();
|
||||
if (user.details != null) {
|
||||
_isLoggedIn = true;
|
||||
_error = null;
|
||||
_stopReconnectLoop();
|
||||
}
|
||||
} on ApiException catch (_) {
|
||||
// Continue trying
|
||||
} catch (e) {
|
||||
debugPrint('Reconnect loop error: $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _stopReconnectLoop() {
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
_reconnectAttempts = 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue