diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 706067cc..520fe380 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -6,6 +6,16 @@ Frosty is a cross-platform Flutter app for Twitch that supports third-party emot
## Critical Development Workflow
+### Essential Commands
+
+**Dependencies and Development:**
+
+- `flutter pub get` - Install dependencies
+- `flutter run` - Run the app on connected device/emulator
+- `flutter build apk` - Build Android APK
+- `flutter build ios` - Build iOS app
+- `flutter analyze` - Run static analysis and lint checks
+
### Code Generation (REQUIRED)
All MobX stores and JSON models require code generation. **Always run after any MobX or model changes:**
@@ -14,14 +24,19 @@ All MobX stores and JSON models require code generation. **Always run after any
flutter packages pub run build_runner build --delete-conflicting-outputs
```
-Generated `.g.dart` files are excluded from linting but must be committed.
+**Alternative commands:**
+
+- `flutter packages pub run build_runner build` - Generate code once
+- `flutter packages pub run build_runner watch` - Watch and regenerate code on changes
+
+Generated `.g.dart` files are excluded from linting but must be committed to source control.
### Environment Setup
Twitch API requires client credentials via `--dart-define`:
```bash
-flutter run --dart-define=clientId=YOUR_CLIENT_ID --dart-define=secret=YOUR_SECRET
+flutter run --dart-define=clientId=YOUR_TWITCH_CLIENT_ID --dart-define=secret=YOUR_TWITCH_CLIENT_SECRET
```
## Architecture Patterns
@@ -35,20 +50,50 @@ flutter run --dart-define=clientId=YOUR_CLIENT_ID --dart-define=secret=YOUR_SECR
### API Service Architecture
-- **Shared HTTP Client**: Single `Client()` instance injected into all API services in `main.dart`
+- **Shared HTTP Client**: Single Dio instance configured in `DioClient.createClient()` with:
+ - Connection pooling and keep-alive headers for efficiency
+ - Optimized timeouts: 8s connect, 15s receive, 10s send
+ - Global Twitch User-Agent header
+ - Simple logging in debug mode (not verbose to avoid log spam)
- **API Services**: `TwitchApi`, `BTTVApi`, `FFZApi`, `SevenTVApi` in `lib/apis/`
-- **Headers Pattern**: Twitch API methods accept `headers` parameter for auth tokens
+- **Authentication Interceptor**: `TwitchAuthInterceptor` automatically injects auth headers:
+ - Detects Twitch API URLs and adds Authorization + Client-Id headers
+ - Updates automatically when user authentication changes
+ - Eliminates manual header passing throughout the codebase
+- **Error Handling**: `UnauthorizedInterceptor` catches 401 errors for token refresh
+- **Service Pattern**: All API services extend `BaseApiClient` for consistent error handling
+
+### Project Structure
+
+**Key Directories:**
+
+- `lib/screens/` - UI screens organized by feature (home, channel, settings, onboarding)
+ - Each screen has its own MobX stores in a `stores/` subdirectory
+- `lib/models/` - Data models with JSON serialization (uses `.g.dart` files)
+- `lib/apis/` - API services for Twitch, BTTV, FFZ, and 7TV
+- `lib/widgets/` - Reusable UI components
+- `lib/cache_manager.dart` - Custom cache management for images/media
+- `lib/utils.dart` - Utility functions and helpers
+- `lib/utils/` - Additional utility modules including context extensions
### Screen Organization
```
lib/screens/{feature}/
├── {feature}.dart # Main UI
-├── {feature}_store.dart # Feature store (NOT at root)
-├── {feature}_store.g.dart # Generated file
+├── stores/ # Feature stores directory
+│ ├── {feature}_store.dart
+│ └── {feature}_store.g.dart
└── subdirectories/ # Feature sub-components
```
+**Main Application Flow:**
+
+1. App starts in `main.dart` with Firebase initialization and dependency injection via Provider
+2. Authentication handled by `AuthStore` using Twitch OAuth
+3. Settings persisted via `SettingsStore` with SharedPreferences
+4. API services inject common HTTP client for efficient connection reuse
+
### Model Generation
- **JSON Serialization**: Models use `@JsonSerializable()` with `.g.dart` companions
@@ -69,12 +114,23 @@ Initialized in `main.dart` with error handling:
- **IRC Connection**: WebSocket to Twitch IRC for real-time chat
- **Emote Loading**: Asynchronous third-party emote fetching (BTTV/FFZ/7TV)
- **Message Parsing**: Custom rendering with emote/badge support
+- **Real-time IRC**: WebSocket connection to Twitch IRC with custom `IRCMessage` parsing
+- **Third-party Emotes**: Asynchronous loading of BTTV, FFZ, and 7TV assets via dedicated APIs
+- **Message Management**: 5000 message limit with 20% batch removal optimization for performance
+- **Assets Store**: Separate `ChatAssetsStore` manages emotes, badges, and chat-related data
+- **User interaction features**: Blocking, reporting, moderation capabilities
### Authentication Flow
- **OAuth**: Twitch OAuth via WebView in `AuthStore`
- **Token Storage**: FlutterSecureStorage for secure token persistence
- **Auto-refresh**: Token validation and refresh logic in `AuthStore`
+- **Two-tier Token System**:
+ - Default app token for unauthenticated requests
+ - Optional user token stored in Flutter Secure Storage
+ - Automatic token validation on startup with fallback
+- **WebView OAuth Flow**: Custom JavaScript injection in WebView for seamless Twitch login experience without opening external browsers
+- **Secure Storage Cleanup**: First-run detection clears secure storage to handle Android/iOS uninstall scenarios where secure storage persists
## Code Style Enforcement
@@ -84,6 +140,9 @@ Analysis rules in `analysis_options.yaml`:
- Package imports: `always_use_package_imports`
- Trailing commas: `require_trailing_commas`
- Final locals: `prefer_final_locals`
+- Additional rules: `directives_ordering`, `avoid_void_async`, `always_declare_return_types`
+
+**Important**: `.g.dart` files are excluded from analysis but must be committed to source control.
## Common Patterns
@@ -109,6 +168,37 @@ autorun((_) => prefs.setString('settings', jsonEncode(settingsStore)));
Use `Future.error()` for API failures, not exceptions.
+## MobX Store Implementation
+
+### Store Pattern
+
+All stores follow `StoreBase with _$StoreName` pattern:
+
+- Generate `.g.dart` files with `flutter packages pub run build_runner build`
+- Use `@observable`, `@action`, and `@computed` annotations
+- Settings store uses `@JsonSerializable()` with automatic persistence via `autorun()`
+
+### Authentication Headers
+
+Generated as `@computed` properties that automatically update when tokens change.
+
+### State Persistence
+
+Settings automatically saved to SharedPreferences via MobX `autorun()` reaction.
+
+## Custom Cache Management
+
+- **Orphaned File Cleanup**: `CustomCacheManager.removeOrphanedCacheFiles()` runs on startup to remove files not in database
+- **Optimized Settings**: 30-day stale period, 10,000 max objects for efficient image caching across the app
+
+## Code Style Guidelines
+
+- **Prefer modern Flutter/Dart features and type-safety**
+- **Use consistent naming, comments, and styles matching the existing codebase**
+- **Single quotes, trailing commas, and final locals per lint rules**
+- **Package imports over relative imports**
+- **Observer widgets only wrap necessary UI components for MobX optimization**
+
## Platform Support
- **iOS/Android**: Native platform features via plugins
diff --git a/.gitignore b/.gitignore
index 2c19596d..702a84bc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,6 +32,7 @@ migrate_working_dir/
.pub-cache/
.pub/
/build/
+/coverage/
# Symbolication related
app.*.symbols
@@ -64,4 +65,5 @@ random/
**/fastlane/metadata/review_information
# ai
-.claude
\ No newline at end of file
+.claude
+.cursor
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
index f288a27e..883cbe35 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2,69 +2,131 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+## Development Workflow
+
+**Plan Mode - In-Depth Planning**: When in plan mode, interview thoroughly before coding:
+
+- Ask detailed questions about technical implementation, UI/UX concerns, tradeoffs, and edge cases
+- Continue asking questions until all requirements and non-obvious decisions are clear
+- Don't begin implementation until all important details are resolved
+
+**Pattern Consistency**: When implementing new code:
+
+- Search the codebase to find existing usages and implementations
+- Prefer following established patterns, styles, and practices for consistency
+
## Development Commands
**Build and Development**
+
- `flutter pub get` - Install dependencies
- `flutter run` - Run the app on connected device/emulator
- `flutter build apk` - Build Android APK
- `flutter build ios` - Build iOS app
- `flutter analyze` - Run static analysis and lint checks
+- `flutter test` - Run tests (note: no test suite currently exists)
**Code Generation (Required for MobX stores and JSON serialization)**
+
- `flutter packages pub run build_runner build` - Generate code once
- `flutter packages pub run build_runner watch` - Watch and regenerate code on changes
- `flutter packages pub run build_runner build --delete-conflicting-outputs` - Force rebuild all generated files
+**Asset Generation**
+
+- `flutter pub run flutter_native_splash:create` - Generate native splash screens
+- `flutter pub run flutter_launcher_icons:main` - Generate app launcher icons
+
**Environment Variables**
-Use `--dart-define` to set environment variables:
-- `--dart-define=clientId=YOUR_TWITCH_CLIENT_ID`
-- `--dart-define=secret=YOUR_TWITCH_CLIENT_SECRET`
+Use `--dart-define` to set environment variables (required for Twitch API access):
+
+```bash
+flutter run --dart-define=clientId=YOUR_TWITCH_CLIENT_ID --dart-define=secret=YOUR_TWITCH_CLIENT_SECRET
+```
## Architecture Overview
-**State Management**: Uses MobX for reactive state management. All stores end with `Store` and have corresponding `.g.dart` generated files. Stores are located within their respective screen directories under `stores/` folders.
+**State Management**: MobX with code generation. All stores end with `Store` and have corresponding `.g.dart` generated files.
**Key Directories**:
+
- `lib/screens/` - UI screens organized by feature (home, channel, settings, onboarding)
- Each screen has its own MobX stores in a `stores/` subdirectory
- `lib/models/` - Data models with JSON serialization (uses .g.dart files)
- `lib/apis/` - API services for Twitch, BTTV, FFZ, and 7TV
- `lib/widgets/` - Reusable UI components
-- `lib/cache_manager.dart` - Custom cache management for images/media
-- `lib/utils.dart` - Utility functions and helpers
+- `lib/utils/` - Utility modules including context extensions
**Main Application Flow**:
+
1. App starts in `main.dart` with Firebase initialization and dependency injection via Provider
2. Authentication handled by `AuthStore` using Twitch OAuth
-3. Settings persisted via `SettingsStore` with SharedPreferences
-4. API services inject common HTTP client for efficient connection reuse
+3. Settings persisted via `SettingsStore` with SharedPreferences and MobX `autorun()`
+4. API services share a common Dio HTTP client for efficient connection reuse
-**Screen Architecture**:
-- Home screen with tabs: Following streams, Top streams, Categories, Search
-- Channel screen with video player and chat functionality
-- Settings screen with account management and app configuration
-- Onboarding flow for first-time users
+**Global Stores** (provided at app root in `main.dart`):
-**Chat System**:
-- Real-time chat via WebSocket connection to Twitch IRC
-- Third-party emote support (BTTV, FFZ, 7TV) loaded asynchronously
-- Chat message parsing and rendering with emote/badge support
-- User interaction features (blocking, reporting, moderation)
+- `AuthStore` - Authentication state and token management
+- `SettingsStore` - User preferences with automatic persistence
-**Third-party Integrations**:
-- Firebase: Crashlytics, Performance monitoring, Analytics
-- Twitch API: Authentication, streams, users, chat
-- BTTV/FFZ/7TV APIs: Extended emote and badge support
+## HTTP Client Architecture
-## Development Notes
+**Centralized Dio Setup**: All API services share a single Dio instance configured in `DioClient.createClient()` with connection pooling, keep-alive headers, and optimized timeouts (8s connect, 15s receive, 10s send).
-**MobX Code Generation**: This project heavily uses MobX for state management and JSON serialization. Any changes to MobX stores or model classes with `@JsonSerializable()` require running the build_runner to regenerate the corresponding `.g.dart` files. Generated files are excluded from analysis in `analysis_options.yaml`.
+**Authentication Interceptor**: `TwitchAuthInterceptor` automatically injects auth headers for Twitch API URLs. Updates automatically when user authentication changes.
-**API Configuration**: Requires Twitch Client ID and Client Secret from dev.twitch.tv console for API access. Use `--dart-define` flags when running the app.
+**API Service Pattern**: All API services extend `BaseApiClient` for consistent error handling. Services: `TwitchApi`, `BTTVApi`, `FFZApi`, `SevenTVApi`.
-**Code Style**: The project enforces specific lint rules including single quotes, trailing commas, final locals, and package imports. Run `flutter analyze` to check for violations.
+## Authentication & Token Management
-**Dependency Injection**: Uses Provider for dependency injection, with all major services (API clients, stores) provided at the app root in `main.dart` with a shared HTTP client for efficiency.
+**Two-tier Token System**:
-**Platform Support**: iOS and Android with platform-specific configurations. Uses Firebase for crashlytics, performance monitoring, and analytics.
\ No newline at end of file
+- Default app token for unauthenticated requests
+- Optional user token stored in Flutter Secure Storage
+- Automatic token validation on startup with fallback
+
+**Secure Storage Cleanup**: First-run detection clears secure storage to handle Android/iOS uninstall scenarios where secure storage persists.
+
+## MobX Store Implementation
+
+**Store Pattern**: All stores follow `StoreBase with _$StoreName` pattern:
+
+```dart
+class SomeStore = SomeStoreBase with _$SomeStore;
+
+abstract class SomeStoreBase with Store {
+ @observable
+ var someValue = '';
+
+ @action
+ void updateValue(String value) => someValue = value;
+
+ @computed
+ String get derivedValue => someValue.toUpperCase();
+}
+```
+
+After any changes to MobX stores or `@JsonSerializable()` models, run the build_runner to regenerate `.g.dart` files.
+
+## Chat System Architecture
+
+- **Real-time IRC**: WebSocket connection to Twitch IRC with custom `IRCMessage` parsing
+- **Third-party Emotes**: Asynchronous loading of BTTV, FFZ, and 7TV assets via dedicated APIs
+- **Message Management**: 5000 message limit with 20% batch removal optimization
+- **Assets Store**: Separate `ChatAssetsStore` manages emotes, badges, and chat-related data
+
+## Code Style
+
+Analysis rules enforced via `analysis_options.yaml`:
+
+- `prefer_single_quotes` - Use single quotes for strings
+- `always_use_package_imports` - Package imports over relative imports
+- `require_trailing_commas` - Trailing commas on multi-line constructs
+- `prefer_final_locals` - Final for local variables
+- `directives_ordering`, `avoid_void_async`, `always_declare_return_types`
+
+Generated `.g.dart` files are excluded from analysis but must be committed.
+
+## Commit Convention
+
+Commits follow conventional format: `refactor:`, `feat:`, `fix:`, `format:`, etc. Keep commits tightly scoped. Include generated `.g.dart` outputs in the same commit as their source changes.
diff --git a/GEMINI.md b/GEMINI.md
deleted file mode 100644
index a7153db2..00000000
--- a/GEMINI.md
+++ /dev/null
@@ -1,35 +0,0 @@
-# GEMINI.md
-
-## Project Overview
-
-This project is a Flutter-based mobile application called "Frosty", a third-party client for the streaming platform Twitch. It aims to provide a better user experience by integrating with popular third-party services like BetterTTV (BTTV), FrankerFaceZ (FFZ), and 7TV, which are not supported by the official Twitch app.
-
-The application is built using the Flutter framework and the Dart programming language. It uses Firebase for analytics and crash reporting, and MobX for state management. The app is available for both Android and iOS.
-
-## Building and Running
-
-To build and run the project, you will need to have Flutter installed.
-
-1. **Clone the repository:**
- ```bash
- git clone https://github.com/tommyxchow/frosty.git
- ```
-2. **Set up Twitch API credentials:**
- * Go to the [Twitch dev console](https://dev.twitch.tv/login) and register a new application to retrieve a **Client ID** and **Client Secret**.
- * Use `--dart-define` to set the `clientId` and `secret` environment variables with your **Client ID** and **Client Secret**.
-3. **Install dependencies:**
- ```bash
- flutter pub get
- ```
-4. **Run the app:**
- ```bash
- flutter run
- ```
-
-## Development Conventions
-
-* **State Management:** The project uses MobX for state management. Code generation is required for MobX stores.
-* **Code Style:** The project uses the `flutter_lints` package to enforce good coding practices.
-* **Dependencies:** Dependencies are managed using the `pubspec.yaml` file.
-* **Firebase:** The project uses Firebase for analytics and crash reporting. A `firebase_options.dart` file is used to configure the Firebase integration.
-* **API Integration:** The application interacts with the Twitch API and other third-party APIs (BTTV, FFZ, 7TV) to fetch data.
diff --git a/analysis_options.yaml b/analysis_options.yaml
index 1b10d3d4..de45531d 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -11,6 +11,11 @@ include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- lib/**.g.dart
+ - .dart_tool/**
+ - build/**
+ - ios/**
+ - android/**
+ - web/**
linter:
# The lint rules applied to this project can be customized in the
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index d6b12e72..36beb448 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -36,15 +36,15 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
CFBundleVersion
1.0
MinimumOSVersion
- 12.0
+ 13.0
diff --git a/ios/Podfile b/ios/Podfile
index 279576f3..f17bddc9 100644
--- a/ios/Podfile
+++ b/ios/Podfile
@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
-# platform :ios, '12.0'
+platform :ios, '15.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 02b8ed1d..847e60b6 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -3,129 +3,130 @@ PODS:
- Flutter
- device_info_plus (0.0.1):
- Flutter
- - Firebase/Analytics (11.10.0):
- - Firebase/Core
- - Firebase/Core (11.10.0):
+ - Firebase/CoreOnly (12.2.0):
+ - FirebaseCore (~> 12.2.0)
+ - Firebase/Crashlytics (12.2.0):
- Firebase/CoreOnly
- - FirebaseAnalytics (~> 11.10.0)
- - Firebase/CoreOnly (11.10.0):
- - FirebaseCore (~> 11.10.0)
- - Firebase/Crashlytics (11.10.0):
+ - FirebaseCrashlytics (~> 12.2.0)
+ - Firebase/Performance (12.2.0):
- Firebase/CoreOnly
- - FirebaseCrashlytics (~> 11.10.0)
- - Firebase/Performance (11.10.0):
- - Firebase/CoreOnly
- - FirebasePerformance (~> 11.10.0)
- - firebase_analytics (11.4.6):
- - Firebase/Analytics (= 11.10.0)
+ - FirebasePerformance (~> 12.2.0)
+ - firebase_analytics (12.0.1):
+ - firebase_core
+ - FirebaseAnalytics (= 12.2.0)
+ - Flutter
+ - firebase_core (4.1.0):
+ - Firebase/CoreOnly (= 12.2.0)
+ - Flutter
+ - firebase_crashlytics (5.0.1):
+ - Firebase/Crashlytics (= 12.2.0)
- firebase_core
- Flutter
- - firebase_core (3.13.1):
- - Firebase/CoreOnly (= 11.10.0)
- - Flutter
- - firebase_crashlytics (4.3.6):
- - Firebase/Crashlytics (= 11.10.0)
+ - firebase_performance (0.11.0-1):
+ - Firebase/Performance (= 12.2.0)
- firebase_core
- Flutter
- - firebase_performance (0.10.1-6):
- - Firebase/Performance (= 11.10.0)
- - firebase_core
- - Flutter
- - FirebaseABTesting (11.10.0):
- - FirebaseCore (~> 11.10.0)
- - FirebaseAnalytics (11.10.0):
- - FirebaseAnalytics/AdIdSupport (= 11.10.0)
- - FirebaseCore (~> 11.10.0)
- - FirebaseInstallations (~> 11.0)
- - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- - GoogleUtilities/MethodSwizzler (~> 8.0)
- - GoogleUtilities/Network (~> 8.0)
- - "GoogleUtilities/NSData+zlib (~> 8.0)"
+ - FirebaseABTesting (12.2.0):
+ - FirebaseCore (~> 12.2.0)
+ - FirebaseAnalytics (12.2.0):
+ - FirebaseAnalytics/Default (= 12.2.0)
+ - FirebaseCore (~> 12.2.0)
+ - FirebaseInstallations (~> 12.2.0)
+ - GoogleUtilities/AppDelegateSwizzler (~> 8.1)
+ - GoogleUtilities/MethodSwizzler (~> 8.1)
+ - GoogleUtilities/Network (~> 8.1)
+ - "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- - FirebaseAnalytics/AdIdSupport (11.10.0):
- - FirebaseCore (~> 11.10.0)
- - FirebaseInstallations (~> 11.0)
- - GoogleAppMeasurement (= 11.10.0)
- - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- - GoogleUtilities/MethodSwizzler (~> 8.0)
- - GoogleUtilities/Network (~> 8.0)
- - "GoogleUtilities/NSData+zlib (~> 8.0)"
+ - FirebaseAnalytics/Default (12.2.0):
+ - FirebaseCore (~> 12.2.0)
+ - FirebaseInstallations (~> 12.2.0)
+ - GoogleAppMeasurement/Default (= 12.2.0)
+ - GoogleUtilities/AppDelegateSwizzler (~> 8.1)
+ - GoogleUtilities/MethodSwizzler (~> 8.1)
+ - GoogleUtilities/Network (~> 8.1)
+ - "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- - FirebaseCore (11.10.0):
- - FirebaseCoreInternal (~> 11.10.0)
- - GoogleUtilities/Environment (~> 8.0)
- - GoogleUtilities/Logger (~> 8.0)
- - FirebaseCoreExtension (11.10.0):
- - FirebaseCore (~> 11.10.0)
- - FirebaseCoreInternal (11.10.0):
- - "GoogleUtilities/NSData+zlib (~> 8.0)"
- - FirebaseCrashlytics (11.10.0):
- - FirebaseCore (~> 11.10.0)
- - FirebaseInstallations (~> 11.0)
- - FirebaseRemoteConfigInterop (~> 11.0)
- - FirebaseSessions (~> 11.0)
- - GoogleDataTransport (~> 10.0)
- - GoogleUtilities/Environment (~> 8.0)
+ - FirebaseCore (12.2.0):
+ - FirebaseCoreInternal (~> 12.2.0)
+ - GoogleUtilities/Environment (~> 8.1)
+ - GoogleUtilities/Logger (~> 8.1)
+ - FirebaseCoreExtension (12.2.0):
+ - FirebaseCore (~> 12.2.0)
+ - FirebaseCoreInternal (12.2.0):
+ - "GoogleUtilities/NSData+zlib (~> 8.1)"
+ - FirebaseCrashlytics (12.2.0):
+ - FirebaseCore (~> 12.2.0)
+ - FirebaseInstallations (~> 12.2.0)
+ - FirebaseRemoteConfigInterop (~> 12.2.0)
+ - FirebaseSessions (~> 12.2.0)
+ - GoogleDataTransport (~> 10.1)
+ - GoogleUtilities/Environment (~> 8.1)
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- - FirebaseInstallations (11.10.0):
- - FirebaseCore (~> 11.10.0)
- - GoogleUtilities/Environment (~> 8.0)
- - GoogleUtilities/UserDefaults (~> 8.0)
+ - FirebaseInstallations (12.2.0):
+ - FirebaseCore (~> 12.2.0)
+ - GoogleUtilities/Environment (~> 8.1)
+ - GoogleUtilities/UserDefaults (~> 8.1)
- PromisesObjC (~> 2.4)
- - FirebasePerformance (11.10.0):
- - FirebaseCore (~> 11.10.0)
- - FirebaseInstallations (~> 11.0)
- - FirebaseRemoteConfig (~> 11.0)
- - FirebaseSessions (~> 11.0)
- - GoogleDataTransport (~> 10.0)
- - GoogleUtilities/Environment (~> 8.0)
- - GoogleUtilities/MethodSwizzler (~> 8.0)
- - GoogleUtilities/UserDefaults (~> 8.0)
+ - FirebasePerformance (12.2.0):
+ - FirebaseCore (~> 12.2.0)
+ - FirebaseInstallations (~> 12.2.0)
+ - FirebaseRemoteConfig (~> 12.2.0)
+ - FirebaseSessions (~> 12.2.0)
+ - GoogleDataTransport (~> 10.1)
+ - GoogleUtilities/Environment (~> 8.1)
+ - GoogleUtilities/MethodSwizzler (~> 8.1)
+ - GoogleUtilities/UserDefaults (~> 8.1)
- nanopb (~> 3.30910.0)
- - FirebaseRemoteConfig (11.10.0):
- - FirebaseABTesting (~> 11.0)
- - FirebaseCore (~> 11.10.0)
- - FirebaseInstallations (~> 11.0)
- - FirebaseRemoteConfigInterop (~> 11.0)
- - FirebaseSharedSwift (~> 11.0)
- - GoogleUtilities/Environment (~> 8.0)
- - "GoogleUtilities/NSData+zlib (~> 8.0)"
- - FirebaseRemoteConfigInterop (11.13.0)
- - FirebaseSessions (11.10.0):
- - FirebaseCore (~> 11.10.0)
- - FirebaseCoreExtension (~> 11.10.0)
- - FirebaseInstallations (~> 11.0)
- - GoogleDataTransport (~> 10.0)
- - GoogleUtilities/Environment (~> 8.0)
- - GoogleUtilities/UserDefaults (~> 8.0)
+ - FirebaseRemoteConfig (12.2.0):
+ - FirebaseABTesting (~> 12.2.0)
+ - FirebaseCore (~> 12.2.0)
+ - FirebaseInstallations (~> 12.2.0)
+ - FirebaseRemoteConfigInterop (~> 12.2.0)
+ - FirebaseSharedSwift (~> 12.2.0)
+ - GoogleUtilities/Environment (~> 8.1)
+ - "GoogleUtilities/NSData+zlib (~> 8.1)"
+ - FirebaseRemoteConfigInterop (12.2.0)
+ - FirebaseSessions (12.2.0):
+ - FirebaseCore (~> 12.2.0)
+ - FirebaseCoreExtension (~> 12.2.0)
+ - FirebaseInstallations (~> 12.2.0)
+ - GoogleDataTransport (~> 10.1)
+ - GoogleUtilities/Environment (~> 8.1)
+ - GoogleUtilities/UserDefaults (~> 8.1)
- nanopb (~> 3.30910.0)
- PromisesSwift (~> 2.1)
- - FirebaseSharedSwift (11.13.0)
+ - FirebaseSharedSwift (12.2.0)
- Flutter (1.0.0)
- flutter_native_splash (2.4.3):
- Flutter
- flutter_secure_storage (6.0.0):
- Flutter
- - GoogleAppMeasurement (11.10.0):
- - GoogleAppMeasurement/AdIdSupport (= 11.10.0)
- - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- - GoogleUtilities/MethodSwizzler (~> 8.0)
- - GoogleUtilities/Network (~> 8.0)
- - "GoogleUtilities/NSData+zlib (~> 8.0)"
+ - GoogleAdsOnDeviceConversion (2.3.0):
+ - GoogleUtilities/Logger (~> 8.1)
+ - GoogleUtilities/Network (~> 8.1)
- nanopb (~> 3.30910.0)
- - GoogleAppMeasurement/AdIdSupport (11.10.0):
- - GoogleAppMeasurement/WithoutAdIdSupport (= 11.10.0)
- - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- - GoogleUtilities/MethodSwizzler (~> 8.0)
- - GoogleUtilities/Network (~> 8.0)
- - "GoogleUtilities/NSData+zlib (~> 8.0)"
+ - GoogleAppMeasurement/Core (12.2.0):
+ - GoogleUtilities/AppDelegateSwizzler (~> 8.1)
+ - GoogleUtilities/MethodSwizzler (~> 8.1)
+ - GoogleUtilities/Network (~> 8.1)
+ - "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- - GoogleAppMeasurement/WithoutAdIdSupport (11.10.0):
- - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
- - GoogleUtilities/MethodSwizzler (~> 8.0)
- - GoogleUtilities/Network (~> 8.0)
- - "GoogleUtilities/NSData+zlib (~> 8.0)"
+ - GoogleAppMeasurement/Default (12.2.0):
+ - GoogleAdsOnDeviceConversion (= 2.3.0)
+ - GoogleAppMeasurement/Core (= 12.2.0)
+ - GoogleAppMeasurement/IdentitySupport (= 12.2.0)
+ - GoogleUtilities/AppDelegateSwizzler (~> 8.1)
+ - GoogleUtilities/MethodSwizzler (~> 8.1)
+ - GoogleUtilities/Network (~> 8.1)
+ - "GoogleUtilities/NSData+zlib (~> 8.1)"
+ - nanopb (~> 3.30910.0)
+ - GoogleAppMeasurement/IdentitySupport (12.2.0):
+ - GoogleAppMeasurement/Core (= 12.2.0)
+ - GoogleUtilities/AppDelegateSwizzler (~> 8.1)
+ - GoogleUtilities/MethodSwizzler (~> 8.1)
+ - GoogleUtilities/Network (~> 8.1)
+ - "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- GoogleDataTransport (10.1.0):
- nanopb (~> 3.30910.0)
@@ -220,6 +221,7 @@ SPEC REPOS:
- FirebaseRemoteConfigInterop
- FirebaseSessions
- FirebaseSharedSwift
+ - GoogleAdsOnDeviceConversion
- GoogleAppMeasurement
- GoogleDataTransport
- GoogleUtilities
@@ -266,30 +268,31 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
advanced_in_app_review: fe8fd50b8670cb3dbe67e4c26ca643f7aea8dbb8
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
- Firebase: 1fe1c0a7d9aaea32efe01fbea5f0ebd8d70e53a2
- firebase_analytics: 943aa4699bf6afe056a96991ba8ee34776323d4c
- firebase_core: 3c2f323cae65c97a636a05a23b17730ef93df2cf
- firebase_crashlytics: 6f5f71ba7ec7b6a0412c4ed076272643c434f69f
- firebase_performance: da79b34e2753057f7028c5e41ac5d8b3c5398eb2
- FirebaseABTesting: dfc10eb6cc08fe3b391ac9e5aa40396d43ea6675
- FirebaseAnalytics: 4e42333f02cf78ed93703a5c36f36dd518aebdef
- FirebaseCore: 8344daef5e2661eb004b177488d6f9f0f24251b7
- FirebaseCoreExtension: 6f357679327f3614e995dc7cf3f2d600bdc774ac
- FirebaseCoreInternal: ef4505d2afb1d0ebbc33162cb3795382904b5679
- FirebaseCrashlytics: 84b073c997235740e6a951b7ee49608932877e5c
- FirebaseInstallations: 9980995bdd06ec8081dfb6ab364162bdd64245c3
- FirebasePerformance: f0bd14be05aaa1136cbb1c9aaaf87d213d0e6fbf
- FirebaseRemoteConfig: 10695bc0ce3b103e3706a5578c43f2a9f69d5aaa
- FirebaseRemoteConfigInterop: 7915cec47731a806cda541f90898ad0fab8f9f86
- FirebaseSessions: 9b3b30947b97a15370e0902ee7a90f50ef60ead6
- FirebaseSharedSwift: aca73668bc95e8efccb618e0167eab05d19d3a75
- Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
+ Firebase: 26f6f8d460603af3df970ad505b16b15f5e2e9a1
+ firebase_analytics: cad607594fc56b3743691f37000d81cde0a86237
+ firebase_core: 3b9065a1037f7f2e360077d2aa5fb6ec9adb3005
+ firebase_crashlytics: 0d84e61860fcc67faf4d3a59664ce13a5622dbc0
+ firebase_performance: 0e63765dc819d5f4b2331000c4f42382fc92a739
+ FirebaseABTesting: 32f3fc079d72c9b93e000b60877c4e4f62ef7031
+ FirebaseAnalytics: e04e23bc070e3014aa5cf4980f9df7ce5cd79ec8
+ FirebaseCore: 311c48a147ad4a0ab7febbaed89e8025c67510cd
+ FirebaseCoreExtension: 73af080c22a2f7b44cefa391dc08f7e4ee162cb5
+ FirebaseCoreInternal: 56ea29f3dad2894f81b060f706f9d53509b6ed3b
+ FirebaseCrashlytics: f83cbf176d5c637ade108c0aacf1ccbd5ec499bf
+ FirebaseInstallations: 3e884b01feabdf67582a80f3250425a00979b4ed
+ FirebasePerformance: 9071eb99f3ed135defc1c145391a82edb98e0b93
+ FirebaseRemoteConfig: e3ae5500b92e813f3a0b12fa09701a26441dfc95
+ FirebaseRemoteConfigInterop: 0896fd52ab72586a355c8f389ff85aaa9e5375e1
+ FirebaseSessions: f4692789e770bec66ce17d772c0e9561c4f11737
+ FirebaseSharedSwift: 52d868d4c269fcb4e4e1310c548435a9c1f46e25
+ Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
- GoogleAppMeasurement: 36684bfb3ee034e2b42b4321eb19da3a1b81e65d
+ GoogleAdsOnDeviceConversion: 9090c435cde08903e8dd1ba2c77fbec9e46d9afe
+ GoogleAppMeasurement: 09f341dfa8527d1612a09cbfe809a242c0b737af
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
- in_app_review: a31b5257259646ea78e0e35fc914979b0031d011
+ in_app_review: 436034b18594851a7328d7f1c2ed5ec235b79cfc
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
@@ -301,6 +304,6 @@ SPEC CHECKSUMS:
wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e
webview_flutter_wkwebview: a4af96a051138e28e29f60101d094683b9f82188
-PODFILE CHECKSUM: c4c93c5f6502fe2754f48404d3594bf779584011
+PODFILE CHECKSUM: 57c8aed26fba39d3ec9424816221f294a07c58eb
COCOAPODS: 1.16.2
diff --git a/lib/apis/base_api_client.dart b/lib/apis/base_api_client.dart
new file mode 100644
index 00000000..5c8a18e0
--- /dev/null
+++ b/lib/apis/base_api_client.dart
@@ -0,0 +1,248 @@
+import 'package:dio/dio.dart';
+
+/// Common type aliases to reduce repetition
+typedef JsonMap = Map;
+typedef JsonList = List;
+typedef QueryParams = Map;
+typedef ApiHeaders = Map;
+
+/// Custom API exceptions for better error handling
+class ApiException implements Exception {
+ final String message;
+ final int? statusCode;
+ final DioExceptionType? type;
+
+ const ApiException(this.message, [this.statusCode, this.type]);
+
+ @override
+ String toString() => 'ApiException: $message';
+}
+
+class NetworkException extends ApiException {
+ const NetworkException(String message)
+ : super(message, null, DioExceptionType.connectionError);
+}
+
+class TimeoutException extends ApiException {
+ const TimeoutException(String message)
+ : super(message, null, DioExceptionType.connectionTimeout);
+}
+
+class ServerException extends ApiException {
+ const ServerException(String message, int statusCode)
+ : super(message, statusCode, DioExceptionType.badResponse);
+}
+
+class NotFoundException extends ApiException {
+ const NotFoundException(String message)
+ : super(message, 404, DioExceptionType.badResponse);
+}
+
+class UnauthorizedException extends ApiException {
+ const UnauthorizedException(String message)
+ : super(message, 401, DioExceptionType.badResponse);
+}
+
+/// Base API client to eliminate code duplication across API services
+abstract class BaseApiClient {
+ final Dio _dio;
+ final String baseUrl;
+
+ const BaseApiClient(this._dio, this.baseUrl);
+
+ /// Builds the full URL, handling both relative endpoints and full URLs
+ String _buildUrl(String endpoint) {
+ // If endpoint is already a full URL (starts with http:// or https://), use it as-is
+ if (endpoint.startsWith('http://') || endpoint.startsWith('https://')) {
+ return endpoint;
+ }
+ // Otherwise, concatenate with base URL
+ return '$baseUrl$endpoint';
+ }
+
+ /// Generic GET request with common error handling
+ Future get(
+ String endpoint, {
+ QueryParams? queryParameters,
+ ApiHeaders? headers,
+ CancelToken? cancelToken,
+ }) async {
+ try {
+ final url = _buildUrl(endpoint);
+ final response = await _dio.get(
+ url,
+ queryParameters: queryParameters,
+ options: Options(headers: headers),
+ cancelToken: cancelToken,
+ );
+ return response.data as T;
+ } on DioException catch (e) {
+ throw _handleError(e);
+ }
+ }
+
+ /// Generic POST request with common error handling
+ Future post(
+ String endpoint, {
+ dynamic data,
+ QueryParams? queryParameters,
+ ApiHeaders? headers,
+ CancelToken? cancelToken,
+ }) async {
+ try {
+ final url = _buildUrl(endpoint);
+ final response = await _dio.post(
+ url,
+ data: data,
+ queryParameters: queryParameters,
+ options: Options(headers: headers),
+ cancelToken: cancelToken,
+ );
+ return response.data as T;
+ } on DioException catch (e) {
+ throw _handleError(e);
+ }
+ }
+
+ /// Generic PUT request with common error handling
+ Future put(
+ String endpoint, {
+ dynamic data,
+ QueryParams? queryParameters,
+ ApiHeaders? headers,
+ CancelToken? cancelToken,
+ }) async {
+ try {
+ final url = _buildUrl(endpoint);
+ final response = await _dio.put(
+ url,
+ data: data,
+ queryParameters: queryParameters,
+ options: Options(headers: headers),
+ cancelToken: cancelToken,
+ );
+ return response.data as T;
+ } on DioException catch (e) {
+ throw _handleError(e);
+ }
+ }
+
+ /// Generic DELETE request with common error handling
+ Future delete(
+ String endpoint, {
+ QueryParams? queryParameters,
+ ApiHeaders? headers,
+ CancelToken? cancelToken,
+ }) async {
+ try {
+ final url = _buildUrl(endpoint);
+ final response = await _dio.delete(
+ url,
+ queryParameters: queryParameters,
+ options: Options(headers: headers),
+ cancelToken: cancelToken,
+ );
+ return response.data as T;
+ } on DioException catch (e) {
+ throw _handleError(e);
+ }
+ }
+
+ /// Check if response indicates success (useful for status-only operations)
+ bool isSuccessResponse(Response response) {
+ return response.statusCode != null &&
+ response.statusCode! >= 200 &&
+ response.statusCode! < 300;
+ }
+
+ /// Converts DioException to appropriate ApiException subtype
+ ApiException _handleError(DioException error) {
+ switch (error.type) {
+ case DioExceptionType.connectionTimeout:
+ case DioExceptionType.sendTimeout:
+ case DioExceptionType.receiveTimeout:
+ return TimeoutException(_getTimeoutMessage(error.type));
+
+ case DioExceptionType.connectionError:
+ return NetworkException(
+ 'No internet connection. Please check your network.',
+ );
+
+ case DioExceptionType.badResponse:
+ return _handleHttpError(error);
+
+ case DioExceptionType.cancel:
+ return ApiException('Request was cancelled');
+
+ case DioExceptionType.badCertificate:
+ return ApiException('Security certificate error');
+
+ case DioExceptionType.unknown:
+ return ApiException(error.message ?? 'An unexpected error occurred');
+ }
+ }
+
+ /// Gets specific timeout message based on timeout type
+ String _getTimeoutMessage(DioExceptionType type) {
+ switch (type) {
+ case DioExceptionType.connectionTimeout:
+ return 'Connection timeout. Please try again.';
+ case DioExceptionType.sendTimeout:
+ return 'Upload timeout. Please try again.';
+ case DioExceptionType.receiveTimeout:
+ return 'Server response timeout. Please try again.';
+ default:
+ return 'Request timeout. Please try again.';
+ }
+ }
+
+ /// Handles HTTP status code errors with specific exception types
+ ApiException _handleHttpError(DioException error) {
+ final statusCode = error.response?.statusCode;
+ final responseData = error.response?.data;
+
+ // Extract error message from server response
+ String? serverMessage;
+ if (responseData is JsonMap) {
+ serverMessage =
+ responseData['message'] as String? ??
+ responseData['error'] as String? ??
+ responseData['error_description'] as String?;
+ }
+
+ switch (statusCode) {
+ case 400:
+ return ApiException(serverMessage ?? 'Invalid request data');
+ case 401:
+ return UnauthorizedException('Please log in to continue');
+ case 403:
+ return ApiException('Access denied. Insufficient permissions');
+ case 404:
+ return NotFoundException(serverMessage ?? 'Resource not found');
+ case 409:
+ return ApiException('Conflict with current state. Please refresh');
+ case 422:
+ return ApiException(serverMessage ?? 'Invalid data provided');
+ case 429:
+ return ApiException(
+ 'Rate limit exceeded. Please wait before trying again',
+ );
+ case 500:
+ return ServerException('Server error. Please try again later', 500);
+ case 502:
+ return ServerException('Service temporarily unavailable', 502);
+ case 503:
+ return ServerException(
+ 'Service maintenance. Please try again later',
+ 503,
+ );
+ case 504:
+ return ServerException('Server timeout. Please try again', 504);
+ default:
+ return ServerException(
+ serverMessage ?? 'HTTP Error $statusCode occurred',
+ statusCode ?? 500,
+ );
+ }
+ }
+}
diff --git a/lib/apis/bttv_api.dart b/lib/apis/bttv_api.dart
index cf47b28e..e65da2a2 100644
--- a/lib/apis/bttv_api.dart
+++ b/lib/apis/bttv_api.dart
@@ -1,75 +1,55 @@
-import 'dart:convert';
-
+import 'package:dio/dio.dart';
+import 'package:frosty/apis/base_api_client.dart';
import 'package:frosty/models/badges.dart';
import 'package:frosty/models/emotes.dart';
-import 'package:http/http.dart';
/// The BTTV service for making API calls.
-class BTTVApi {
- final Client _client;
-
- const BTTVApi(this._client);
+class BTTVApi extends BaseApiClient {
+ BTTVApi(Dio dio) : super(dio, 'https://api.betterttv.net/3/cached');
/// Returns a map of global BTTV emotes to their URL.
Future> getEmotesGlobal() async {
- final url = Uri.parse('https://api.betterttv.net/3/cached/emotes/global');
+ final data = await get('/emotes/global');
- final response = await _client.get(url);
- if (response.statusCode == 200) {
- final decoded = jsonDecode(response.body) as List;
- final emotes = decoded.map((emote) => EmoteBTTV.fromJson(emote)).toList();
+ final emotes = data.map((emote) => EmoteBTTV.fromJson(emote)).toList();
- return emotes
- .map((emote) => Emote.fromBTTV(emote, EmoteType.bttvGlobal))
- .toList();
- } else {
- return Future.error('Failed to get BTTV global emotes');
- }
+ return emotes
+ .map((emote) => Emote.fromBTTV(emote, EmoteType.bttvGlobal))
+ .toList();
}
/// Returns a map of a channel's BTTV emotes to their URL.
Future> getEmotesChannel({required String id}) async {
- final url =
- Uri.parse('https://api.betterttv.net/3/cached/users/twitch/$id');
+ final data = await get('/users/twitch/$id');
- final response = await _client.get(url);
- if (response.statusCode == 200) {
- final decoded = jsonDecode(response.body);
- final result = EmoteBTTVChannel.fromJson(decoded);
+ final result = EmoteBTTVChannel.fromJson(data);
- final emoteToUrl = [];
- emoteToUrl.addAll(
- result.channelEmotes
- .map((emote) => Emote.fromBTTV(emote, EmoteType.bttvChannel)),
- );
- emoteToUrl.addAll(
- result.sharedEmotes
- .map((emote) => Emote.fromBTTV(emote, EmoteType.bttvShared)),
- );
+ final emoteToUrl = [];
+ emoteToUrl.addAll(
+ result.channelEmotes.map(
+ (emote) => Emote.fromBTTV(emote, EmoteType.bttvChannel),
+ ),
+ );
+ emoteToUrl.addAll(
+ result.sharedEmotes.map(
+ (emote) => Emote.fromBTTV(emote, EmoteType.bttvShared),
+ ),
+ );
- return emoteToUrl;
- } else {
- return Future.error('Failed to get BTTV channel emotes');
- }
+ return emoteToUrl;
}
/// Returns a map of username to their BTTV badge.
Future