diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..dc0da85 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,53 @@ +name: Test Suite + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + test: + name: Run Tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + channel: 'stable' + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Run code generation + run: flutter packages pub run build_runner build --delete-conflicting-outputs + + - name: Verify formatting + run: dart format --output=none --set-exit-if-changed lib/ test/ + + - name: Analyze code + run: flutter analyze --no-fatal-infos + + - name: Run tests + run: flutter test --coverage + + - name: Check test coverage + run: | + # Parse coverage and report + LINES=$(grep -oP '(?<=LF:)\d+' coverage/lcov.info | awk '{s+=$1} END {print s}') + HITS=$(grep -oP '(?<=LH:)\d+' coverage/lcov.info | awk '{s+=$1} END {print s}') + COVERAGE=$(echo "scale=1; $HITS * 100 / $LINES" | bc) + echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Coverage**: $COVERAGE% ($HITS / $LINES lines)" >> $GITHUB_STEP_SUMMARY + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-report + path: coverage/lcov.info + retention-days: 5 diff --git a/.gitignore b/.gitignore index 590c68f..2a01c6d 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,24 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release -.fvm/flutter_sdk \ No newline at end of file +.fvm/flutter_sdk + +# Coverage +coverage/ + +# Test output and debug files +*.txt +*_output*.log +*_failures*.log + +# Python (mock backend) +__pycache__/ +*.py[cod] +*$py.class +.Python +*.egg-info/ +venv/ +.env + +# Android +**/android/local.properties \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8f6fe94 --- /dev/null +++ b/Makefile @@ -0,0 +1,86 @@ +.PHONY: help test test-watch coverage gen clean analyze format quick-check install run run-mock build + +help: ## Show this help message + @echo 'Usage: make [target]' + @echo '' + @echo 'Available targets:' + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +# Development +install: ## Install dependencies + flutter pub get + flutter packages pub run build_runner build --delete-conflicting-outputs + +run: ## Run app in Chrome + flutter run -d chrome --web-port=8080 + +run-mock: ## Run app with mock backend + @echo "Starting app in Mock Mode..." + flutter run -d chrome --web-port=8080 --dart-define=MOCK_MODE=true + +mock-server: ## Run the mock backend server + python3 tools/mock_backend/mock_backend.py + +install-mock-deps: ## Install dependencies for mock backend + pip3 install aiohttp aiohttp-cors websockets + +# Testing +test: ## Run all tests + flutter test + +test-watch: ## Run tests in watch mode + flutter test --watch + +coverage: ## Generate coverage report and open in browser + flutter test --coverage + @if command -v genhtml > /dev/null; then \ + genhtml coverage/lcov.info -o coverage/html -q; \ + open coverage/html/index.html || xdg-open coverage/html/index.html; \ + else \ + echo "genhtml not found. Install lcov: brew install lcov (macOS) or apt-get install lcov (Linux)"; \ + fi + +# Code Generation +gen: ## Run build_runner code generation + flutter packages pub run build_runner build --delete-conflicting-outputs + +gen-watch: ## Watch for changes and regenerate code + flutter packages pub run build_runner watch --delete-conflicting-outputs + +# Code Quality +analyze: ## Run static analysis + flutter analyze + +format: ## Format code + dart format lib/ test/ + +format-check: ## Check code formatting + dart format --set-exit-if-changed lib/ test/ + +# Combined Tasks +quick-check: format analyze test ## Run format, analyze, and tests + +pre-commit: format-check analyze test ## Pre-commit checks (fails on format issues) + +# Cleanup +clean: ## Clean build artifacts + flutter clean + rm -rf coverage/ + +clean-all: clean ## Clean everything including dependencies + rm -rf .dart_tool/ + rm -rf build/ + rm -f pubspec.lock + +reset: clean-all install ## Full reset: clean and reinstall + +# Build +build: ## Build web app for production + flutter build web + +build-profile: ## Build web app in profile mode + flutter build web --profile + +# Other +doctor: ## Check Flutter installation + flutter doctor -v diff --git a/README.md b/README.md index 43ee28c..0c57772 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,50 @@ # Tesla Android +[![Tests](https://img.shields.io/badge/tests-340%20passing-success)](test/README.md) +[![Coverage](https://img.shields.io/badge/coverage-80.1%25-success)](coverage/html/index.html) +[![Flutter](https://img.shields.io/badge/flutter-%3E%3D3.35.0-blue)](https://flutter.dev) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) + Flutter app for Tesla Android. -Please refer to https://teslaandroid.com for release notes, hardware requirements and the install guide. +Please refer to [teslaandroid.com](https://teslaandroid.com) for release notes, hardware requirements, and the install guide. -## Getting Started +## Quick Start -``` -flutter pub get -flutter packages pub run build_runner build --delete-conflicting-outputs -flutter build web +```bash +# Install app dependencies +make install + +# Install mock backend dependencies +make install-mock-deps + +# Start mock server (in a separate terminal) +make mock-server + +# Run app (mock mode) +make run-mock + +# Run tests +make test + +# Generate coverage +make coverage ``` -In order to build this project for debugging make sure to disable cors in Chrome and connect to Tesla Android Wi-Fi network +See [`Makefile`](Makefile) for all available commands. -#### Please consider supporting the project: +## Development -[Donations](https://teslaandroid.com/donations) +**Test guide**: [`test/README.md`](test/README.md) + +**Mock backend**: [`docs/MOCK_BACKEND.md`](docs/MOCK_BACKEND.md) + +## Hardware Debugging +When connecting to real Tesla Android hardware: +1. Connect to Tesla Android Wi-Fi network +2. Disable CORS in Chrome + +## Please Consider Supporting the Project + +[Donations](https://teslaandroid.com/donations) diff --git a/analyze_coverage.dart b/analyze_coverage.dart new file mode 100644 index 0000000..3f8e7ea --- /dev/null +++ b/analyze_coverage.dart @@ -0,0 +1,58 @@ +// ignore_for_file: avoid_print +import 'dart:io'; + +void main() { + final file = File('coverage/lcov.info'); + if (!file.existsSync()) { + print('coverage/lcov.info not found'); + return; + } + + final lines = file.readAsLinesSync(); + final Map> coverage = {}; + String? currentFile; + + for (final line in lines) { + if (line.startsWith('SF:')) { + currentFile = line.substring(3); + if (currentFile.endsWith('.g.dart')) { + currentFile = null; + continue; + } + coverage[currentFile] = [0, 0]; // [hit, total] + } else if (line.startsWith('DA:') && currentFile != null) { + final parts = line.substring(3).split(','); + final hits = int.parse(parts[1]); + coverage[currentFile]![1]++; // total lines + if (hits > 0) { + coverage[currentFile]![0]++; // hit lines + } + } + } + + int totalHits = 0; + int totalLines = 0; + + print('Coverage Report:'); + print('----------------'); + + final sortedFiles = coverage.keys.toList()..sort(); + + for (final file in sortedFiles) { + final stats = coverage[file]!; + final hits = stats[0]; + final total = stats[1]; + totalHits += hits; + totalLines += total; + final percent = total > 0 ? (hits / total * 100).toStringAsFixed(1) : '0.0'; + + // Filter out files that are 100% covered to focus on gaps + if (hits < total) { + print('$percent% ($hits/$total) - $file'); + } + } + + print('----------------'); + final totalPercent = totalLines > 0 ? (totalHits / totalLines * 100).toStringAsFixed(1) : '0.0'; + print('Total Coverage: $totalPercent% ($totalHits/$totalLines)'); +} diff --git a/docs/MOCK_BACKEND.md b/docs/MOCK_BACKEND.md new file mode 100644 index 0000000..c31c871 --- /dev/null +++ b/docs/MOCK_BACKEND.md @@ -0,0 +1,216 @@ +# Mock Backend for Local Development + +Enables testing Tesla Android Flutter app without hardware by simulating the backend API and WebSocket connections. + +## Quick Start + +### 1. Install Dependencies +```bash +pip3 install aiohttp aiohttp-cors +``` + +### 2. Start Mock Server +```bash +python3 tools/mock_backend/mock_backend.py +``` + +Server runs on `http://localhost:3000` + +### 3. Run App in Mock Mode +``` +http://localhost:8080?mock=true +``` + +The `?mock=true` URL parameter automatically configures the app to use the mock backend. + +**Visual Indicators**: +- 🟠 **Orange** = Mock mode +- 🟢 **Green** = Dev mode (real backend) +- 🔴 **Red** = Production mode + +--- + +## What's Mocked + +### ✅ REST API Endpoints +| Endpoint | Methods | Description | +|----------|---------|-------------| +| `/api/health` | GET | Health checks | +| `/api/configuration` | GET/POST | System configuration | +| `/api/displayState` | GET/POST | Display settings | +| `/api/deviceInfo` | GET | Device info (temp, model) | +| `/api/softApBand` | POST | Wi-Fi band | +| `/api/softApChannel` | POST | Wi-Fi channel | +| `/api/browserAudioState` | POST | Audio enable/disable | +| `/api/browserAudioVolume` | POST | Audio volume (0-100) | +| `/api/gpsState` | POST | GPS enable/disable | + +### ✅ WebSocket Endpoints +- `/sockets/display` - Display streaming +- `/sockets/touchscreen` - Touch input +- `/sockets/gps` - GPS data +- `/sockets/audio` - Audio streaming + +### ✅ Debug Endpoints +- `GET /api/debug/state` - View current mock state +- `GET /api/debug/websockets` - Active connections +- `POST /api/debug/reset` - Reset to defaults + +--- + +## Testing Workflow + +### 1. Start Mock Backend +```bash +python3 tools/mock_backend/mock_backend.py +``` + +Expected output: +``` +====================================================================== +Tesla Android Mock Backend - Raw WebSocket Support +====================================================================== + +Server starting on http://localhost:3000 + +Raw WebSocket Endpoints: + ws://localhost:3000/sockets/display + ws://localhost:3000/sockets/touchscreen + ws://localhost:3000/sockets/gps + ws://localhost:3000/sockets/audio +... +``` + +### 2. Open App with Mock Parameter +``` +http://localhost:8080?mock=true +``` + +### 3. Verify Connections +Check mock backend terminal for: +``` +[Display WebSocket] Client connected +[Touchscreen WebSocket] Client connected +[GPS WebSocket] Client connected +[Audio WebSocket] Client connected +[GET] /api/health +[GET] /api/configuration +[GET] /api/displayState +[GET] /api/deviceInfo +``` + +### 4. Test Features +- Navigate to settings +- Toggle GPS, audio settings +- Change display configuration +- Check device info +- Interact with touchscreen + +All changes are logged in the mock backend terminal. + +--- + +## Mock State + +The server maintains in-memory state (resets on restart): + +**Display**: 1920x1080, h264, 30Hz, quality 90 +**Configuration**: Wi-Fi enabled, GPS active, audio enabled +**Device**: CM4, ~45°C CPU temp, serial "MOCK0000001" + +View current state: `GET http://localhost:3000/api/debug/state` + +--- + +## Backend File + +**Use**: `tools/mock_backend/mock_backend.py` (aiohttp-based with raw WebSocket protocol) + +--- + +## Troubleshooting + +### Connection Refused +- ✅ Mock backend running on port 3000 +- ✅ URL includes `?mock=true` +- ✅ No firewall blocking port 3000 + +### Still Shows "Connection Lost" +1. Open Chrome DevTools → Network tab +2. Look for requests to `localhost:3000/api/health` +3. If missing, try hot **restart** (R) not hot reload (r) + +### WebSocket 404 Errors +- ✅ Ensure using `tools/mock_backend/mock_backend.py` +- ✅ Check terminal shows WebSocket connections + +### Settings Don't Update +1. Check mock terminal shows POST requests +2. Try toggling setting off/on +3. Hot restart app + +--- + +## Production Mode + +Remove `?mock=true` to connect to real Tesla Android backend: + +``` +http://localhost:8080 +``` + +App automatically uses `device.teslaandroid.com` (green/red indicator). + +--- + +## Implementation Details + +### Dev Mode Guard +**File**: `lib/common/di/app_module.dart` + +```dart +// Checks for ?mock=true in URL +final isMockMode = window.location.search.contains('mock=true'); + +if (isMockMode) { + // Use localhost:3000 +} else { + // Use device.teslaandroid.com +} +``` + +**Safe to commit**: Defaults to production mode, no breaking changes. + +### URL Parameter Benefits +- ✅ No code changes to toggle modes +- ✅ Explicit testing mode (`?mock=true`) +- ✅ Visual indicator (orange color) +- ✅ Production-safe default + +--- + +## Use Cases + +✅ **SDK/dependency testing** without hardware +✅ **UI development** and iteration +✅ **Settings testing** and validation +✅ **API integration** testing +✅ **New contributor onboarding** + +❌ **Display streaming validation** (mock doesn't send real video) +❌ **Performance testing** (mock has no real load) +❌ **Hardware-specific features** (modem, CarPlay detection) + +Use real hardware for final validation. + +--- + +## Summary + +**Mock Backend**: Fully functional REST + WebSocket +**Activation**: `?mock=true` URL parameter +**No Hardware Required**: Test locally without Tesla Android device +**Production Safe**: Defaults to real backend + +**Development**: `http://localhost:8080?mock=true` +**Production**: `http://localhost:8080` diff --git a/lib/common/di/app_module.dart b/lib/common/di/app_module.dart index ddaffa3..eeebcaa 100644 --- a/lib/common/di/app_module.dart +++ b/lib/common/di/app_module.dart @@ -1,40 +1,34 @@ import 'package:flavor/flavor.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:injectable/injectable.dart' hide Environment; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:web/web.dart'; +import 'package:tesla_android/common/di/flavor_factory.dart'; +import 'package:tesla_android/common/service/audio_service.dart'; +import 'package:tesla_android/common/service/audio_service_factory.dart'; +import 'package:tesla_android/feature/touchscreen/service/message_sender.dart'; +import 'package:tesla_android/common/service/window_service.dart'; +import 'package:tesla_android/common/service/window_service_factory.dart'; +import 'package:tesla_android/feature/touchscreen/service/message_sender_factory.dart'; @module abstract class AppModule { @singleton - Flavor get provideFlavor { - const defaultDomain = "device.teslaandroid.com"; - final isLocalHost = window.location.hostname.contains("localhost"); - final domain = isLocalHost ? defaultDomain : window.location.hostname; - final isSSL = window.location.protocol.contains("https") || kDebugMode; - final httpProtocol = isSSL ? "https://" : "http://"; - final webSocketProtocol = isSSL ? "wss://" : "ws://"; - return Flavor.create( - isLocalHost ? Environment.dev : Environment.production, - color: isLocalHost ? Colors.green : Colors.red, - properties: { - 'isSSL': isSSL, - 'touchscreenWebSocket': '$webSocketProtocol$domain/sockets/touchscreen', - 'gpsWebSocket': '$webSocketProtocol$domain/sockets/gps', - 'audioWebSocket': '$webSocketProtocol$domain/sockets/audio', - 'displayWebSocket': '$webSocketProtocol$domain/sockets/display', - //'displayWebSocket': '$httpProtocol$domain/stream', - 'configurationApiBaseUrl': '$httpProtocol$domain/api', - }, - ); - } + Flavor get provideFlavor => FlavorFactory.create(); @singleton @preResolve Future get sharedPreferences => SharedPreferences.getInstance(); + @singleton + WindowService get windowService => WindowServiceFactory.create(); + + @singleton + MessageSender get messageSender => MessageSenderFactory.create(); + + @singleton + AudioService get audioService => AudioServiceFactory.create(); + @lazySingleton GlobalKey get navigatorKey => GlobalKey(); } diff --git a/lib/common/di/flavor_factory.dart b/lib/common/di/flavor_factory.dart new file mode 100644 index 0000000..d7522ed --- /dev/null +++ b/lib/common/di/flavor_factory.dart @@ -0,0 +1,7 @@ +import 'package:flavor/flavor.dart'; +import 'flavor_factory_stub.dart' + if (dart.library.js_interop) 'flavor_factory_web.dart'; + +class FlavorFactory { + static Flavor create() => createFlavor(); +} diff --git a/lib/common/di/flavor_factory_stub.dart b/lib/common/di/flavor_factory_stub.dart new file mode 100644 index 0000000..8e49524 --- /dev/null +++ b/lib/common/di/flavor_factory_stub.dart @@ -0,0 +1,17 @@ +import 'package:flavor/flavor.dart'; +import 'package:flutter/material.dart'; + +Flavor createFlavor() { + return Flavor.create( + Environment.dev, + color: Colors.blue, + properties: { + 'isSSL': false, + 'touchscreenWebSocket': 'ws://localhost:3000/sockets/touchscreen', + 'gpsWebSocket': 'ws://localhost:3000/sockets/gps', + 'audioWebSocket': 'ws://localhost:3000/sockets/audio', + 'displayWebSocket': 'ws://localhost:3000/sockets/display', + 'configurationApiBaseUrl': 'http://localhost:3000/api', + }, + ); +} diff --git a/lib/common/di/flavor_factory_web.dart b/lib/common/di/flavor_factory_web.dart new file mode 100644 index 0000000..362f9a3 --- /dev/null +++ b/lib/common/di/flavor_factory_web.dart @@ -0,0 +1,55 @@ +import 'package:flavor/flavor.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:web/web.dart'; + +Flavor createFlavor() { + // Check for mock mode via URL parameter: ?mock=true + // OR via Dart environment variable: --dart-define=MOCK_MODE=true + final isMockMode = + window.location.search.contains('mock=true') || + const bool.fromEnvironment('MOCK_MODE', defaultValue: false); + + if (isMockMode) { + // MOCK BACKEND MODE for local testing + const mockBackendDomain = "localhost:3000"; + const httpProtocol = "http://"; + const webSocketProtocol = "ws://"; + + return Flavor.create( + Environment.dev, + color: Colors.orange, // Orange indicates mock mode + properties: { + 'isSSL': false, + 'touchscreenWebSocket': + '$webSocketProtocol$mockBackendDomain/sockets/touchscreen', + 'gpsWebSocket': '$webSocketProtocol$mockBackendDomain/sockets/gps', + 'audioWebSocket': '$webSocketProtocol$mockBackendDomain/sockets/audio', + 'displayWebSocket': + '$webSocketProtocol$mockBackendDomain/sockets/display', + 'configurationApiBaseUrl': '$httpProtocol$mockBackendDomain/api', + }, + ); + } else { + // PRODUCTION MODE - normal Tesla Android backend + const defaultDomain = "device.teslaandroid.com"; + final isLocalHost = window.location.hostname.contains("localhost"); + final domain = isLocalHost ? defaultDomain : window.location.hostname; + final isSSL = window.location.protocol.contains("https") || kDebugMode; + final httpProtocol = isSSL ? "https://" : "http://"; + final webSocketProtocol = isSSL ? "wss://" : "ws://"; + + return Flavor.create( + isLocalHost ? Environment.dev : Environment.production, + color: isLocalHost ? Colors.green : Colors.red, + properties: { + 'isSSL': isSSL, + 'touchscreenWebSocket': '$webSocketProtocol$domain/sockets/touchscreen', + 'gpsWebSocket': '$webSocketProtocol$domain/sockets/gps', + 'audioWebSocket': '$webSocketProtocol$domain/sockets/audio', + 'displayWebSocket': '$webSocketProtocol$domain/sockets/display', + 'configurationApiBaseUrl': '$httpProtocol$domain/api', + }, + ); + } +} diff --git a/lib/common/di/network_module.dart b/lib/common/di/network_module.dart index e64e8ec..74e3254 100644 --- a/lib/common/di/network_module.dart +++ b/lib/common/di/network_module.dart @@ -5,10 +5,10 @@ import 'package:injectable/injectable.dart'; abstract class NetworkModule { @singleton Dio get dio => Dio( - BaseOptions( - connectTimeout: const Duration(seconds: 5), - receiveTimeout: const Duration(seconds: 5), - sendTimeout: const Duration(seconds: 5), - ), - ); + BaseOptions( + connectTimeout: const Duration(seconds: 5), + receiveTimeout: const Duration(seconds: 5), + sendTimeout: const Duration(seconds: 5), + ), + ); } diff --git a/lib/common/di/ta_locator.config.dart b/lib/common/di/ta_locator.config.dart index ef1b0b4..454dac1 100644 --- a/lib/common/di/ta_locator.config.dart +++ b/lib/common/di/ta_locator.config.dart @@ -24,6 +24,9 @@ import 'package:tesla_android/common/network/device_info_service.dart' as _i723; import 'package:tesla_android/common/network/display_service.dart' as _i856; import 'package:tesla_android/common/network/github_service.dart' as _i10; import 'package:tesla_android/common/network/health_service.dart' as _i483; +import 'package:tesla_android/common/service/audio_service.dart' as _i204; +import 'package:tesla_android/common/service/dialog_service.dart' as _i651; +import 'package:tesla_android/common/service/window_service.dart' as _i570; import 'package:tesla_android/feature/connectivityCheck/cubit/connectivity_check_cubit.dart' as _i747; import 'package:tesla_android/feature/display/cubit/display_cubit.dart' as _i14; @@ -54,6 +57,8 @@ import 'package:tesla_android/feature/settings/repository/system_configuration_r as _i608; import 'package:tesla_android/feature/touchscreen/cubit/touchscreen_cubit.dart' as _i680; +import 'package:tesla_android/feature/touchscreen/service/message_sender.dart' + as _i44; extension GetItInjectableX on _i174.GetIt { // initializes the registration of main-scope dependencies inside of GetIt @@ -65,7 +70,6 @@ extension GetItInjectableX on _i174.GetIt { final appModule = _$AppModule(); final networkModule = _$NetworkModule(); gh.factory<_i557.TAPageFactory>(() => _i557.TAPageFactory()); - gh.factory<_i680.TouchscreenCubit>(() => _i680.TouchscreenCubit()); gh.factory<_i841.ReleaseNotesRepository>( () => _i841.ReleaseNotesRepository(), ); @@ -74,7 +78,11 @@ extension GetItInjectableX on _i174.GetIt { () => appModule.sharedPreferences, preResolve: true, ); + gh.singleton<_i570.WindowService>(() => appModule.windowService); + gh.singleton<_i44.MessageSender>(() => appModule.messageSender); + gh.singleton<_i204.AudioService>(() => appModule.audioService); gh.singleton<_i361.Dio>(() => networkModule.dio); + gh.singleton<_i651.DialogService>(() => _i651.DialogService()); gh.lazySingleton<_i409.GlobalKey<_i409.NavigatorState>>( () => appModule.navigatorKey, ); @@ -97,12 +105,6 @@ extension GetItInjectableX on _i174.GetIt { () => _i608.SystemConfigurationRepository(gh<_i302.ConfigurationService>()), ); - gh.factory<_i365.SystemConfigurationCubit>( - () => _i365.SystemConfigurationCubit( - gh<_i608.SystemConfigurationRepository>(), - gh<_i409.GlobalKey<_i409.NavigatorState>>(), - ), - ); gh.factory<_i865.GitHubReleaseRepository>( () => _i865.GitHubReleaseRepository( gh<_i10.GitHubService>(), @@ -120,6 +122,11 @@ extension GetItInjectableX on _i174.GetIt { gh<_i608.SystemConfigurationRepository>(), ), ); + gh.factory<_i365.SystemConfigurationCubit>( + () => _i365.SystemConfigurationCubit( + gh<_i608.SystemConfigurationRepository>(), + ), + ); gh.factory<_i825.AudioConfigurationCubit>( () => _i825.AudioConfigurationCubit( gh<_i608.SystemConfigurationRepository>(), @@ -128,6 +135,15 @@ extension GetItInjectableX on _i174.GetIt { gh.factory<_i708.DeviceInfoRepository>( () => _i708.DeviceInfoRepository(gh<_i723.DeviceInfoService>()), ); + gh.factory<_i680.TouchscreenCubit>( + () => _i680.TouchscreenCubit(gh<_i44.MessageSender>()), + ); + gh.factory<_i747.ConnectivityCheckCubit>( + () => _i747.ConnectivityCheckCubit( + gh<_i483.HealthService>(), + gh<_i570.WindowService>(), + ), + ); gh.factory<_i271.DisplayRepository>( () => _i271.DisplayRepository( gh<_i856.DisplayService>(), @@ -137,22 +153,18 @@ extension GetItInjectableX on _i174.GetIt { gh.factory<_i399.ReleaseNotesCubit>( () => _i399.ReleaseNotesCubit(gh<_i841.ReleaseNotesRepository>()), ); - gh.factory<_i747.ConnectivityCheckCubit>( - () => _i747.ConnectivityCheckCubit(gh<_i483.HealthService>()), - ); gh.factory<_i588.RearDisplayConfigurationCubit>( () => _i588.RearDisplayConfigurationCubit(gh<_i271.DisplayRepository>()), ); gh.factory<_i685.DisplayConfigurationCubit>( () => _i685.DisplayConfigurationCubit(gh<_i271.DisplayRepository>()), ); + gh.factory<_i14.DisplayCubit>( + () => _i14.DisplayCubit(gh<_i271.DisplayRepository>()), + ); gh.factory<_i1064.DeviceInfoCubit>( () => _i1064.DeviceInfoCubit(gh<_i708.DeviceInfoRepository>()), ); - gh.factory<_i14.DisplayCubit>( - () => - _i14.DisplayCubit(gh<_i271.DisplayRepository>(), gh<_i544.Flavor>()), - ); return this; } } diff --git a/lib/common/navigation/ta_navigator.dart b/lib/common/navigation/ta_navigator.dart index 8fcf1ee..edbe5b4 100644 --- a/lib/common/navigation/ta_navigator.dart +++ b/lib/common/navigation/ta_navigator.dart @@ -7,10 +7,7 @@ import 'package:tesla_android/common/navigation/ta_page_type.dart'; class TANavigator { static TAPageFactory get _pageFactory => getIt(); - static Future push({ - required BuildContext context, - required TAPage page, - }) { + static Future push({required BuildContext context, required TAPage page}) { switch (page.type) { case TAPageType.standard: return Navigator.of(context).pushNamed(page.route); @@ -31,13 +28,15 @@ class TANavigator { ); } - static void pushReplacement( - {required BuildContext context, - required TAPage page, - bool animated = true}) { + static void pushReplacement({ + required BuildContext context, + required TAPage page, + bool animated = true, + }) { if (page.type != TAPageType.standard) { throw UnsupportedError( - "only regular pages can be used in pushReplacement"); + "only regular pages can be used in pushReplacement", + ); } if (animated) { Navigator.of(context).pushReplacementNamed(page.route); @@ -49,15 +48,13 @@ class TANavigator { }, transitionDuration: Duration.zero, reverseTransitionDuration: Duration.zero, - settings: RouteSettings(name: page.route) + settings: RouteSettings(name: page.route), ), ); } } - static void pop({ - required BuildContext context, - }) { + static void pop({required BuildContext context}) { Navigator.of(context).pop(); } } diff --git a/lib/common/navigation/ta_page.dart b/lib/common/navigation/ta_page.dart index 60fb975..9a216ad 100644 --- a/lib/common/navigation/ta_page.dart +++ b/lib/common/navigation/ta_page.dart @@ -5,11 +5,7 @@ class TAPage { final String route; final TAPageType type; - const TAPage({ - required this.title, - required this.route, - required this.type, - }); + const TAPage({required this.title, required this.route, required this.type}); static const empty = TAPage( title: "Empty", diff --git a/lib/common/navigation/ta_page_type.dart b/lib/common/navigation/ta_page_type.dart index e8ea2e3..a43af8c 100644 --- a/lib/common/navigation/ta_page_type.dart +++ b/lib/common/navigation/ta_page_type.dart @@ -1 +1 @@ -enum TAPageType { standard, dialog } \ No newline at end of file +enum TAPageType { standard, dialog } diff --git a/lib/common/network/base_websocket_transport.dart b/lib/common/network/base_websocket_transport.dart deleted file mode 100644 index 8700c81..0000000 --- a/lib/common/network/base_websocket_transport.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:flavor/flavor.dart'; -import 'package:tesla_android/common/di/ta_locator.dart'; -import 'package:tesla_android/common/utils/logger.dart'; -import 'package:web_socket_client/web_socket_client.dart'; - -abstract class BaseWebsocketTransport with Logger { - final String flavorUrlKey; - final bool sendKeepAlive; - String? binaryType; - - Flavor get _flavor => getIt(); - - WebSocket? _webSocketChannel; - - BaseWebsocketTransport({ - required this.flavorUrlKey, - this.binaryType, - this.sendKeepAlive = false, - }); - - bool get _isConnected => - _webSocketChannel?.connection.state is Connected || - _webSocketChannel?.connection.state is Reconnected; - - void connect() { - if (_isConnected) { - return; - } - _connect(); - } - - void disconnect() { - _webSocketChannel?.close(); - _webSocketChannel = null; - } - - Future _connect() async { - _webSocketChannel = WebSocket( - Uri.parse(_flavor.getString( - flavorUrlKey, - )!), - binaryType: binaryType ?? "blob", - pingInterval: const Duration(seconds: 30), - timeout: const Duration(seconds: 10), - backoff: BinaryExponentialBackoff( - initial: const Duration(seconds: 1), - maximumStep: 10, - ), - ); - _webSocketChannel?.messages.listen((message) { - onMessage(message); - }); - _webSocketChannel?.connection.listen( - (event) { - if (event is Connected) { - log("Connected"); - } else if (event is Connecting) { - log("Connecting"); - } else if (event is Reconnected) { - log("Reconnected"); - } else if (event is Reconnecting) { - log("Reconnecting"); - } else if (event is Disconnected) { - log("Disconnected"); - } else if (event is Disconnecting) { - log("Disconnecting"); - } - }, - ); - } - - void onMessage(event) { - // optional - } - - void onOpen() { - //optional - } - - void send(dynamic message) { - if (!_isConnected) return; - _webSocketChannel?.send(message); - } - - void sendString(String string) { - if (!_isConnected) return; - _webSocketChannel?.send(string); - } - - void sendJson(object) { - if (!_isConnected) return; - final jsonString = jsonEncode(object); - sendString(jsonString); - } - - void sendBlob(blob) { - if (!_isConnected) return; - _webSocketChannel?.send(blob); - } - - void sendByteBuffer(ByteBuffer byteBuffer) { - if (!_isConnected) return; - _webSocketChannel?.send(byteBuffer); - } - - void sendTypedData(TypedData typedData) { - if (!_isConnected) return; - _webSocketChannel?.send(typedData); - } -} diff --git a/lib/common/network/configuration_service.dart b/lib/common/network/configuration_service.dart index 236aa76..fb1abc9 100644 --- a/lib/common/network/configuration_service.dart +++ b/lib/common/network/configuration_service.dart @@ -10,76 +10,52 @@ part 'configuration_service.g.dart'; @RestApi() abstract class ConfigurationService { @factoryMethod - factory ConfigurationService( - Dio dio, - Flavor flavor, - ) => - _ConfigurationService( - dio, - baseUrl: flavor.getString("configurationApiBaseUrl"), - ); + factory ConfigurationService(Dio dio, Flavor flavor) => _ConfigurationService( + dio, + baseUrl: flavor.getString("configurationApiBaseUrl"), + ); @GET("/configuration") @DioResponseType(ResponseType.json) Future getConfiguration(); @POST("/softApBand") - @Headers({ - "Content-Type": "text/plain", - }) + @Headers({"Content-Type": "text/plain"}) Future setSoftApBand(@Body() int band); @POST("/softApChannel") - @Headers({ - "Content-Type": "text/plain", - }) + @Headers({"Content-Type": "text/plain"}) Future setSoftApChannel(@Body() int channel); @POST("/softApChannelWidth") - @Headers({ - "Content-Type": "text/plain", - }) + @Headers({"Content-Type": "text/plain"}) Future setSoftApChannelWidth(@Body() int channelWidth); @POST("/softApState") - @Headers({ - "Content-Type": "text/plain", - }) + @Headers({"Content-Type": "text/plain"}) Future setSoftApState(@Body() int isEnabledFlag); @POST("/offlineModeState") - @Headers({ - "Content-Type": "text/plain", - }) + @Headers({"Content-Type": "text/plain"}) Future setOfflineModeState(@Body() int isEnabledFlag); @POST("/offlineModeTelemetryState") - @Headers({ - "Content-Type": "text/plain", - }) + @Headers({"Content-Type": "text/plain"}) Future setOfflineModeTelemetryState(@Body() int isEnabledFlag); @POST("/offlineModeTeslaFirmwareDownloads") - @Headers({ - "Content-Type": "text/plain", - }) + @Headers({"Content-Type": "text/plain"}) Future setOfflineModeTeslaFirmwareDownloads(@Body() int isEnabledFlag); @POST("/browserAudioState") - @Headers({ - "Content-Type": "text/plain", - }) + @Headers({"Content-Type": "text/plain"}) Future setBrowserAudioState(@Body() int isEnabledFlag); @POST("/browserAudioVolume") - @Headers({ - "Content-Type": "text/plain", - }) + @Headers({"Content-Type": "text/plain"}) Future setBrowserAudioVolume(@Body() int volume); @POST("/gpsState") - @Headers({ - "Content-Type": "text/plain", - }) + @Headers({"Content-Type": "text/plain"}) Future setGPSState(@Body() int state); } diff --git a/lib/common/network/device_info_service.dart b/lib/common/network/device_info_service.dart index 0cbd674..6f8d2bc 100644 --- a/lib/common/network/device_info_service.dart +++ b/lib/common/network/device_info_service.dart @@ -10,14 +10,10 @@ part 'device_info_service.g.dart'; @RestApi() abstract class DeviceInfoService { @factoryMethod - factory DeviceInfoService( - Dio dio, - Flavor flavor, - ) => - _DeviceInfoService( - dio, - baseUrl: flavor.getString("configurationApiBaseUrl"), - ); + factory DeviceInfoService(Dio dio, Flavor flavor) => _DeviceInfoService( + dio, + baseUrl: flavor.getString("configurationApiBaseUrl"), + ); @GET("/deviceInfo") @DioResponseType(ResponseType.json) diff --git a/lib/common/network/display_service.dart b/lib/common/network/display_service.dart index 795d7c2..782755d 100644 --- a/lib/common/network/display_service.dart +++ b/lib/common/network/display_service.dart @@ -10,22 +10,16 @@ part 'display_service.g.dart'; @RestApi() abstract class DisplayService { @factoryMethod - factory DisplayService( - Dio dio, - Flavor flavor, - ) => - _DisplayService( - dio, - baseUrl: flavor.getString("configurationApiBaseUrl"), - ); + factory DisplayService(Dio dio, Flavor flavor) => _DisplayService( + dio, + baseUrl: flavor.getString("configurationApiBaseUrl"), + ); @GET("/displayState") @DioResponseType(ResponseType.json) Future getDisplayState(); @POST("/displayState") - @Headers({ - "Content-Type": "application/json", - }) + @Headers({"Content-Type": "application/json"}) Future updateDisplayConfiguration(@Body() RemoteDisplayState configuration); } diff --git a/lib/common/network/github_service.dart b/lib/common/network/github_service.dart index bb0e53e..242ec9e 100644 --- a/lib/common/network/github_service.dart +++ b/lib/common/network/github_service.dart @@ -10,17 +10,10 @@ part 'github_service.g.dart'; @RestApi() abstract class GitHubService { @factoryMethod - factory GitHubService( - Dio dio, - Flavor flavor, - ) => - _GitHubService( - dio, - baseUrl: "https://api.github.com", - ); + factory GitHubService(Dio dio, Flavor flavor) => + _GitHubService(dio, baseUrl: "https://api.github.com"); @GET("/repos/tesla-android/android-raspberry-pi/releases/latest") - @Headers({ - "X-GitHub-Api-Version": "2022-11-28", - }) Future getLatestRelease(); + @Headers({"X-GitHub-Api-Version": "2022-11-28"}) + Future getLatestRelease(); } diff --git a/lib/common/network/health_service.dart b/lib/common/network/health_service.dart index 5ee4bf9..561d2ba 100644 --- a/lib/common/network/health_service.dart +++ b/lib/common/network/health_service.dart @@ -9,14 +9,8 @@ part 'health_service.g.dart'; @RestApi() abstract class HealthService { @factoryMethod - factory HealthService( - Dio dio, - Flavor flavor, - ) => - _HealthService( - dio, - baseUrl: flavor.getString("configurationApiBaseUrl"), - ); + factory HealthService(Dio dio, Flavor flavor) => + _HealthService(dio, baseUrl: flavor.getString("configurationApiBaseUrl")); @GET("/health") Future getHealthCheck(); diff --git a/lib/common/service/audio_service.dart b/lib/common/service/audio_service.dart new file mode 100644 index 0000000..c47c694 --- /dev/null +++ b/lib/common/service/audio_service.dart @@ -0,0 +1,9 @@ +import 'package:flutter/foundation.dart'; + +abstract class AudioService { + void setupAudioConfig(String configJson); + void startAudioFromGesture(); + void stopAudio(); + String getAudioState(); + VoidCallback addAudioStateListener(void Function(String state) onState); +} diff --git a/lib/common/service/audio_service_factory.dart b/lib/common/service/audio_service_factory.dart new file mode 100644 index 0000000..9343d85 --- /dev/null +++ b/lib/common/service/audio_service_factory.dart @@ -0,0 +1,9 @@ +import 'package:tesla_android/common/service/audio_service.dart'; +import 'package:tesla_android/common/service/audio_service_stub.dart' + if (dart.library.js_interop) 'package:tesla_android/common/service/web_audio_service.dart'; + +class AudioServiceFactory { + static AudioService create() { + return createAudioService(); + } +} diff --git a/lib/common/service/audio_service_stub.dart b/lib/common/service/audio_service_stub.dart new file mode 100644 index 0000000..0790334 --- /dev/null +++ b/lib/common/service/audio_service_stub.dart @@ -0,0 +1,31 @@ +import 'package:flutter/foundation.dart'; +import 'package:tesla_android/common/service/audio_service.dart'; + +class AudioServiceStub implements AudioService { + @override + void setupAudioConfig(String configJson) { + // No-op + } + + @override + void startAudioFromGesture() { + // No-op + } + + @override + void stopAudio() { + // No-op + } + + @override + String getAudioState() { + return 'stopped'; + } + + @override + VoidCallback addAudioStateListener(void Function(String state) onState) { + return () {}; + } +} + +AudioService createAudioService() => AudioServiceStub(); diff --git a/lib/common/service/dialog_service.dart b/lib/common/service/dialog_service.dart new file mode 100644 index 0000000..6dae575 --- /dev/null +++ b/lib/common/service/dialog_service.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:injectable/injectable.dart'; + +/// Service for handling dialogs and banners +/// +/// Wraps Flutter's dialog and scaffold messenger methods to allow for mocking in tests. +@singleton +class DialogService { + /// Shows a material banner + void showMaterialBanner({ + required BuildContext context, + required MaterialBanner banner, + }) { + ScaffoldMessenger.of(context).showMaterialBanner(banner); + } + + /// Clears all material banners + void clearMaterialBanners({required BuildContext context}) { + ScaffoldMessenger.of(context).clearMaterialBanners(); + } + + /// Shows a general dialog + Future showDialog({ + required BuildContext context, + required WidgetBuilder builder, + bool barrierDismissible = true, + }) { + return showDialog( + context: context, + builder: builder, + barrierDismissible: barrierDismissible, + ); + } +} diff --git a/lib/common/service/web_audio_service.dart b/lib/common/service/web_audio_service.dart new file mode 100644 index 0000000..21c72bc --- /dev/null +++ b/lib/common/service/web_audio_service.dart @@ -0,0 +1,61 @@ +import 'dart:js_interop'; +import 'package:flutter/foundation.dart'; +import 'package:tesla_android/common/service/audio_service.dart'; +import 'package:web/web.dart' as web; + +@JS('setupAudioConfig') +external void _setupAudioConfig(String configJson); + +@JS('startAudioFromGesture') +external void _startAudioFromGesture(); + +@JS('stopAudio') +external void _stopAudio(); + +@JS('getAudioState') +external String _getAudioState(); + +class WebAudioService implements AudioService { + @override + void setupAudioConfig(String configJson) { + _setupAudioConfig(configJson); + } + + @override + void startAudioFromGesture() { + _startAudioFromGesture(); + } + + @override + void stopAudio() { + _stopAudio(); + } + + @override + String getAudioState() { + return _getAudioState(); + } + + @override + VoidCallback addAudioStateListener(void Function(String state) onState) { + final jsListener = (web.Event e) { + // We know 'audio-state' is a CustomEvent + final customEvent = e as web.CustomEvent; + final detail = customEvent.detail; // JSAny? + + if (detail != null) { + // We expect detail to be a JSString + final state = (detail as JSString).toDart; + onState(state); + } + }.toJS; + + web.window.addEventListener('audio-state', jsListener); + + return () { + web.window.removeEventListener('audio-state', jsListener); + }; + } +} + +AudioService createAudioService() => WebAudioService(); diff --git a/lib/common/service/web_window_service.dart b/lib/common/service/web_window_service.dart new file mode 100644 index 0000000..b238cb2 --- /dev/null +++ b/lib/common/service/web_window_service.dart @@ -0,0 +1,11 @@ +import 'package:tesla_android/common/service/window_service.dart'; +import 'package:web/web.dart' as web; + +class WebWindowService implements WindowService { + @override + void reload() { + web.window.location.reload(); + } +} + +WindowService createWindowService() => WebWindowService(); diff --git a/lib/common/service/window_service.dart b/lib/common/service/window_service.dart new file mode 100644 index 0000000..024c22d --- /dev/null +++ b/lib/common/service/window_service.dart @@ -0,0 +1,3 @@ +abstract class WindowService { + void reload(); +} diff --git a/lib/common/service/window_service_factory.dart b/lib/common/service/window_service_factory.dart new file mode 100644 index 0000000..38aa92b --- /dev/null +++ b/lib/common/service/window_service_factory.dart @@ -0,0 +1,7 @@ +import 'package:tesla_android/common/service/window_service.dart'; +import 'window_service_stub.dart' + if (dart.library.js_interop) 'web_window_service.dart'; + +class WindowServiceFactory { + static WindowService create() => createWindowService(); +} diff --git a/lib/common/service/window_service_stub.dart b/lib/common/service/window_service_stub.dart new file mode 100644 index 0000000..a6f3d0f --- /dev/null +++ b/lib/common/service/window_service_stub.dart @@ -0,0 +1,10 @@ +import 'package:tesla_android/common/service/window_service.dart'; + +class WindowServiceStub implements WindowService { + @override + void reload() { + // No-op on VM + } +} + +WindowService createWindowService() => WindowServiceStub(); diff --git a/lib/common/ui/components/settings_dropdown.dart b/lib/common/ui/components/settings_dropdown.dart new file mode 100644 index 0000000..30f0dfc --- /dev/null +++ b/lib/common/ui/components/settings_dropdown.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +/// A reusable dropdown widget for settings pages +/// +/// Provides consistent styling and behavior across all settings dropdowns. +/// Type-safe with generics. +class SettingsDropdown extends StatelessWidget { + final T value; + final List items; + final ValueChanged onChanged; + final String Function(T) itemLabel; + final Color? underlineColor; + + const SettingsDropdown({ + super.key, + required this.value, + required this.items, + required this.onChanged, + required this.itemLabel, + this.underlineColor, + }); + + @override + Widget build(BuildContext context) { + return DropdownButton( + value: value, + icon: const Icon(Icons.arrow_drop_down_outlined), + underline: Container( + height: 2, + color: underlineColor ?? Theme.of(context).primaryColor, + ), + onChanged: onChanged, + items: items.map>((T item) { + return DropdownMenuItem(value: item, child: Text(itemLabel(item))); + }).toList(), + ); + } +} + +/// Extension for display enums to provide readable names +extension DisplayRendererTypeExt on Object { + String displayName() { + if (this is Enum) { + final enumValue = this as Enum; + // Check if the enum has a name() method + try { + return (this as dynamic).name(); + } catch (e) { + // Fallback to enum name + return enumValue.name; + } + } + return toString(); + } +} diff --git a/lib/common/ui/components/settings_error_widget.dart b/lib/common/ui/components/settings_error_widget.dart new file mode 100644 index 0000000..8a9ffeb --- /dev/null +++ b/lib/common/ui/components/settings_error_widget.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:tesla_android/common/ui/constants/ta_colors.dart'; + +/// Standardized error widget for settings pages +/// +/// Provides consistent error display across all settings widgets +/// with optional retry functionality. +class SettingsErrorWidget extends StatelessWidget { + final String message; + final VoidCallback? onRetry; + final IconData icon; + + const SettingsErrorWidget({ + super.key, + this.message = 'Service error', + this.onRetry, + this.icon = Icons.error_outline, + }); + + /// Factory constructor for common "fetch error" scenario + factory SettingsErrorWidget.fetchError({VoidCallback? onRetry}) { + return SettingsErrorWidget( + message: 'Failed to load settings', + onRetry: onRetry, + icon: Icons.cloud_off, + ); + } + + /// Factory constructor for common "save error" scenario + factory SettingsErrorWidget.saveError({VoidCallback? onRetry}) { + return SettingsErrorWidget( + message: 'Failed to save settings', + onRetry: onRetry, + icon: Icons.save_outlined, + ); + } + + /// Factory constructor for common "connection error" scenario + factory SettingsErrorWidget.connectionError({VoidCallback? onRetry}) { + return SettingsErrorWidget( + message: 'Connection lost', + onRetry: onRetry, + icon: Icons.wifi_off, + ); + } + + @override + Widget build(BuildContext context) { + if (onRetry == null) { + // Simple inline error text (backward compatible) + return Text(message, style: TextStyle(color: TAColors.errorColor)); + } + + // Full error widget with retry button + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 48, color: TAColors.errorColor), + const SizedBox(height: 16), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle(color: TAColors.errorColor, fontSize: 16), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/common/ui/components/settings_slider.dart b/lib/common/ui/components/settings_slider.dart new file mode 100644 index 0000000..8376f48 --- /dev/null +++ b/lib/common/ui/components/settings_slider.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:tesla_android/common/ui/constants/ta_colors.dart'; + +/// A reusable slider widget for settings pages +/// +/// Provides consistent styling and behavior across all settings sliders. +/// Includes a text label showing the current value. +class SettingsSlider extends StatelessWidget { + final double value; + final ValueChanged onChanged; + final double min; + final double max; + final int? divisions; + final String label; + final String suffix; + + const SettingsSlider({ + super.key, + required this.value, + required this.onChanged, + required this.min, + required this.max, + this.divisions, + required this.label, + this.suffix = '', + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + "$label$suffix", + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: TAColors.settingsPrimaryColor, + ), + ), + Slider( + divisions: divisions, + min: min, + max: max, + value: value, + onChanged: onChanged, + label: label, + ), + ], + ); + } +} diff --git a/lib/common/ui/components/settings_switch.dart b/lib/common/ui/components/settings_switch.dart new file mode 100644 index 0000000..979968d --- /dev/null +++ b/lib/common/ui/components/settings_switch.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; + +/// A reusable switch widget for settings pages +/// +/// Provides consistent styling and behavior across all settings switches. +class SettingsSwitch extends StatelessWidget { + final bool value; + final ValueChanged onChanged; + final bool enabled; + + const SettingsSwitch({ + super.key, + required this.value, + required this.onChanged, + this.enabled = true, + }); + + @override + Widget build(BuildContext context) { + return Switch(value: value, onChanged: enabled ? onChanged : null); + } +} diff --git a/lib/common/ui/components/ta_bottom_navigation_bar.dart b/lib/common/ui/components/ta_bottom_navigation_bar.dart index 6ed3983..3dd6ec6 100644 --- a/lib/common/ui/components/ta_bottom_navigation_bar.dart +++ b/lib/common/ui/components/ta_bottom_navigation_bar.dart @@ -13,10 +13,7 @@ class TaBottomNavigationBar extends StatelessWidget with Logger { return BottomNavigationBar( type: BottomNavigationBarType.fixed, items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.android), - label: 'Android OS', - ), + BottomNavigationBarItem(icon: Icon(Icons.android), label: 'Android OS'), BottomNavigationBarItem( icon: Icon(Icons.info_outlined), label: 'About', @@ -26,20 +23,22 @@ class TaBottomNavigationBar extends StatelessWidget with Logger { label: 'Release Notes', ), BottomNavigationBarItem( - icon: Icon(Icons.monetization_on), label: "Donations"), - BottomNavigationBarItem( - icon: Icon(Icons.settings), - label: 'Settings', + icon: Icon(Icons.monetization_on), + label: "Donations", ), + BottomNavigationBarItem(icon: Icon(Icons.settings), label: 'Settings'), ], currentIndex: currentIndex, onTap: (index) { final page = _getPageForIndex(index); - if(page == null) { + if (page == null) { return; } TANavigator.pushReplacement( - context: context, page: page, animated: index == 0); + context: context, + page: page, + animated: index == 0, + ); }, ); } diff --git a/lib/common/ui/components/ta_bottom_sheet.dart b/lib/common/ui/components/ta_bottom_sheet.dart index 134451c..f80191d 100644 --- a/lib/common/ui/components/ta_bottom_sheet.dart +++ b/lib/common/ui/components/ta_bottom_sheet.dart @@ -4,11 +4,7 @@ class TABottomSheet extends StatelessWidget { final String title; final Widget body; - const TABottomSheet({ - super.key, - required this.title, - required this.body, - }); + const TABottomSheet({super.key, required this.title, required this.body}); @override Widget build(BuildContext context) { @@ -30,11 +26,8 @@ class TABottomSheet extends StatelessWidget { return Padding( padding: const EdgeInsets.symmetric(vertical: 15), child: Center( - child: Container( - width: 100, - height: 4, - color: Colors.grey.shade500, - )), + child: Container(width: 100, height: 4, color: Colors.grey.shade500), + ), ); } @@ -44,23 +37,17 @@ class TABottomSheet extends StatelessWidget { child: Stack( children: [ Padding( - padding: const EdgeInsets.symmetric( - vertical: 40, - ), - child: Center( - child: Text( - title, - style: Theme.of(context).textTheme.titleLarge, - ), - )), + padding: const EdgeInsets.symmetric(vertical: 40), + child: Center( + child: Text(title, style: Theme.of(context).textTheme.titleLarge), + ), + ), Positioned( - left: 0, - bottom: 0, - right: 0, - child: Container( - height: 1, - color: Colors.grey.shade500, - )) + left: 0, + bottom: 0, + right: 0, + child: Container(height: 1, color: Colors.grey.shade500), + ), ], ), ); diff --git a/lib/common/ui/constants/ta_colors.dart b/lib/common/ui/constants/ta_colors.dart index 5f6bb2b..bf08350 100644 --- a/lib/common/ui/constants/ta_colors.dart +++ b/lib/common/ui/constants/ta_colors.dart @@ -2,4 +2,5 @@ import 'package:flutter/material.dart'; class TAColors { static const settingsPrimaryColor = Color(0xFFB71C1C); + static const errorColor = Color(0xFFD32F2F); } diff --git a/lib/common/ui/constants/ta_dimens.dart b/lib/common/ui/constants/ta_dimens.dart index ecd181d..ce1bc0e 100644 --- a/lib/common/ui/constants/ta_dimens.dart +++ b/lib/common/ui/constants/ta_dimens.dart @@ -25,12 +25,18 @@ class TADimens { static const PADDING_XXL = EdgeInsets.all(PADDING_XXL_VALUE); static const PADDING_XXXL = EdgeInsets.all(PADDING_XXXL_VALUE); - static const VERTICAL_PADDING_S = - EdgeInsets.only(top: PADDING_S_VALUE, bottom: PADDING_S_VALUE); - static const VERTICAL_PADDING = - EdgeInsets.only(top: PADDING_VALUE, bottom: PADDING_VALUE); - static const VERTICAL_PADDING_L = - EdgeInsets.only(top: PADDING_L_VALUE, bottom: PADDING_L_VALUE); + static const VERTICAL_PADDING_S = EdgeInsets.only( + top: PADDING_S_VALUE, + bottom: PADDING_S_VALUE, + ); + static const VERTICAL_PADDING = EdgeInsets.only( + top: PADDING_VALUE, + bottom: PADDING_VALUE, + ); + static const VERTICAL_PADDING_L = EdgeInsets.only( + top: PADDING_L_VALUE, + bottom: PADDING_L_VALUE, + ); static const ROUND_BORDER_RADIUS_XXS = 8.0; static const ROUND_BORDER_RADIUS_XS = 10.0; @@ -52,16 +58,20 @@ class TADimens { static const halfBaseContentMargin = 8.0; static const halfBasePadding = EdgeInsets.all(halfBaseContentMargin); - static const halfBasePaddingHorizontal = - EdgeInsets.symmetric(horizontal: halfBaseContentMargin); - static const halfBasePaddingVertical = - EdgeInsets.symmetric(vertical: halfBaseContentMargin); + static const halfBasePaddingHorizontal = EdgeInsets.symmetric( + horizontal: halfBaseContentMargin, + ); + static const halfBasePaddingVertical = EdgeInsets.symmetric( + vertical: halfBaseContentMargin, + ); static const basePadding = EdgeInsets.all(baseContentMargin); - static const basePaddingHorizontal = - EdgeInsets.symmetric(horizontal: baseContentMargin); - static const basePaddingVertical = - EdgeInsets.symmetric(vertical: baseContentMargin); + static const basePaddingHorizontal = EdgeInsets.symmetric( + horizontal: baseContentMargin, + ); + static const basePaddingVertical = EdgeInsets.symmetric( + vertical: baseContentMargin, + ); static const splashPageLogoHeight = 200.0; static const splashPageRadius = 25.0; diff --git a/lib/common/utils/audio_api.dart b/lib/common/utils/audio_api.dart deleted file mode 100644 index 39f8b41..0000000 --- a/lib/common/utils/audio_api.dart +++ /dev/null @@ -1,40 +0,0 @@ -@JS() -library; - -import 'dart:js_interop'; -import 'package:web/web.dart' as web; - -@JS('setupAudioConfig') -external void setupAudioConfig(String configJson); - -@JS('startAudioFromGesture') -external void startAudioFromGesture(); - -@JS('stopAudio') -external void stopAudio(); - -@JS('getAudioState') -external String getAudioState(); - -VoidCallback addAudioStateListener(void Function(String state) onState) { - final jsListener = (web.Event e) { - String? state; - if (e is web.CustomEvent) { - final detail = e.detail; // JSAny? - if (detail is JSString) { - state = detail.toDart; - } - } - if (state != null) { - onState(state!); - } - }.toJS; - - web.window.addEventListener('audio-state', jsListener); - - return () { - web.window.removeEventListener('audio-state', jsListener); - }; -} - -typedef VoidCallback = void Function(); \ No newline at end of file diff --git a/lib/common/utils/logger.dart b/lib/common/utils/logger.dart index e853328..1270970 100644 --- a/lib/common/utils/logger.dart +++ b/lib/common/utils/logger.dart @@ -1,10 +1,26 @@ +// ignore_for_file: avoid_print + +import 'package:dio/dio.dart'; + mixin Logger { void log(String message) { - print("[$runtimeType $hashCode] $message" ); + print("[$runtimeType $hashCode] $message"); } - void logException({exception, StackTrace? stackTrace}) { + void logException({dynamic exception, StackTrace? stackTrace}) { + if (exception is DioException && + (exception.type == DioExceptionType.connectionError || + exception.type == DioExceptionType.unknown)) { + print("[$runtimeType] Network Error: ${exception.message}"); + print( + "[$runtimeType] Hint: If running locally without hardware, use '?mock=true' in the URL.", + ); + return; + } + print("[$runtimeType] ${exception.toString()}"); - print("[$runtimeType] ${stackTrace.toString()}"); + if (stackTrace != null) { + print("[$runtimeType] ${stackTrace.toString()}"); + } } } diff --git a/lib/feature/about/about_page.dart b/lib/feature/about/about_page.dart index 9cc429a..fe66da2 100644 --- a/lib/feature/about/about_page.dart +++ b/lib/feature/about/about_page.dart @@ -11,39 +11,42 @@ class AboutPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: TaAppBar( - title: TAPage.about.title, - ), - body: Center( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox( - width: TADimens.splashPageLogoHeight, - height: TADimens.splashPageLogoHeight, - child: ClipRRect( - borderRadius: BorderRadius.circular(TADimens.splashPageRadius), - child: Image.asset("images/png/tesla-android-logo.png"), - ), - ), - const SizedBox( - height: TADimens.PADDING_VALUE, + appBar: TaAppBar(title: TAPage.about.title), + body: Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: TADimens.splashPageLogoHeight, + height: TADimens.splashPageLogoHeight, + child: ClipRRect( + borderRadius: BorderRadius.circular(TADimens.splashPageRadius), + child: Image.asset("images/png/tesla-android-logo.png"), ), - const Text("Version", style: TextStyle(fontWeight: FontWeight.bold)), - const SizedBox( - height: TADimens.PADDING_S_VALUE, - ), - FutureBuilder(future: PackageInfo.fromPlatform(), builder: (_, snapshot){ + ), + const SizedBox(height: TADimens.PADDING_VALUE), + const Text( + "Version", + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: TADimens.PADDING_S_VALUE), + FutureBuilder( + future: PackageInfo.fromPlatform(), + builder: (_, snapshot) { return Text(snapshot.data?.version ?? ''); - }), - const SizedBox( - height: TADimens.PADDING_XXL_VALUE, - ), - const Text("Tesla Android (2nd generation) hardware is now available.\nRetrofits are available for 1st generation devices.\nMore details on https://teslaandroid.com", style: TextStyle(fontWeight: FontWeight.bold), textAlign: TextAlign.center,), - ], - ), + }, + ), + const SizedBox(height: TADimens.PADDING_XXL_VALUE), + const Text( + "Tesla Android (2nd generation) hardware is now available.\nRetrofits are available for 1st generation devices.\nMore details on https://teslaandroid.com", + style: TextStyle(fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + ], ), - bottomNavigationBar: const TaBottomNavigationBar(currentIndex: 1)); + ), + bottomNavigationBar: const TaBottomNavigationBar(currentIndex: 1), + ); } } diff --git a/lib/feature/connectivityCheck/cubit/connectivity_check_cubit.dart b/lib/feature/connectivityCheck/cubit/connectivity_check_cubit.dart index b777fb3..722e5c9 100644 --- a/lib/feature/connectivityCheck/cubit/connectivity_check_cubit.dart +++ b/lib/feature/connectivityCheck/cubit/connectivity_check_cubit.dart @@ -1,34 +1,45 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:injectable/injectable.dart'; import 'package:tesla_android/common/network/health_service.dart'; +import 'package:tesla_android/common/service/window_service.dart'; import 'package:tesla_android/feature/connectivityCheck/model/connectivity_state.dart'; -import 'package:web/web.dart'; @injectable class ConnectivityCheckCubit extends Cubit { final HealthService _healthService; + final WindowService _windowService; - ConnectivityCheckCubit(this._healthService) - : super(ConnectivityState.backendAccessible) { + @visibleForTesting + void Function()? onReloadOverride; + + ConnectivityCheckCubit(this._healthService, this._windowService) + : super(ConnectivityState.backendAccessible) { _observeBackendAccessibility(); } static const _standardCheckInterval = Duration(seconds: 30); static const _offlineCheckInterval = Duration(seconds: 5); + Timer? _standardTimer; + Timer? _offlineTimer; + void _observeBackendAccessibility() { - checkConnectivity(); - Timer.periodic(_standardCheckInterval, (timer) async { - if (state == ConnectivityState.backendAccessible) checkConnectivity(); + _standardTimer = Timer.periodic(_standardCheckInterval, (timer) async { + if (state == ConnectivityState.backendAccessible) { + await checkConnectivity(); + } }); - Timer.periodic(_offlineCheckInterval, (timer) async { - if (state != ConnectivityState.backendAccessible) checkConnectivity(); + _offlineTimer = Timer.periodic(_offlineCheckInterval, (timer) async { + if (state != ConnectivityState.backendAccessible) { + await checkConnectivity(); + } }); } - void checkConnectivity() async { + Future checkConnectivity() async { try { await _healthService.getHealthCheck(); _onRequestSuccess(); @@ -38,13 +49,26 @@ class ConnectivityCheckCubit extends Cubit { } void _onRequestFailure() async { - emit(ConnectivityState.backendUnreachable); + if (!isClosed) emit(ConnectivityState.backendUnreachable); } void _onRequestSuccess() { if (state == ConnectivityState.backendUnreachable) { - window.location.reload(); + if (onReloadOverride != null) { + onReloadOverride!(); + } else { + _windowService.reload(); + } + } + if (!isClosed) { + emit(ConnectivityState.backendAccessible); } - emit(ConnectivityState.backendAccessible); } -} \ No newline at end of file + + @override + Future close() { + _standardTimer?.cancel(); + _offlineTimer?.cancel(); + return super.close(); + } +} diff --git a/lib/feature/connectivityCheck/model/connectivity_state.dart b/lib/feature/connectivityCheck/model/connectivity_state.dart index 9686847..5c4695b 100644 --- a/lib/feature/connectivityCheck/model/connectivity_state.dart +++ b/lib/feature/connectivityCheck/model/connectivity_state.dart @@ -1 +1 @@ -enum ConnectivityState { backendAccessible, backendUnreachable } \ No newline at end of file +enum ConnectivityState { backendAccessible, backendUnreachable } diff --git a/lib/feature/display/cubit/display_cubit.dart b/lib/feature/display/cubit/display_cubit.dart index a4bc0bc..26ad3d2 100644 --- a/lib/feature/display/cubit/display_cubit.dart +++ b/lib/feature/display/cubit/display_cubit.dart @@ -3,38 +3,32 @@ import 'dart:math' as math; import 'dart:ui' hide window; -import 'package:flavor/flavor.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:injectable/injectable.dart'; -import 'package:tesla_android/common/network/base_websocket_transport.dart'; import 'package:tesla_android/common/utils/logger.dart'; import 'package:tesla_android/feature/display/cubit/display_state.dart'; import 'package:tesla_android/feature/display/model/remote_display_state.dart'; import 'package:tesla_android/feature/display/repository/display_repository.dart'; +@injectable @injectable class DisplayCubit extends Cubit with Logger { final DisplayRepository _repository; - final Flavor _flavor; - - DisplayCubit(this._repository, this._flavor) : super(DisplayStateInitial()); - BaseWebsocketTransport? activeTransport; + DisplayCubit(this._repository) : super(DisplayStateInitial()); - StreamSubscription? _transportStreamSubscription; Timer? _resizeCoolDownTimer; static const Duration _coolDownDuration = Duration(seconds: 1); @override Future close() { _resizeCoolDownTimer?.cancel(); - _transportStreamSubscription?.cancel(); log("close"); return super.close(); } - void resizeDisplay({required Size viewSize}) { + Future resizeDisplay({required Size viewSize}) async { if (state is DisplayStateResizeInProgress) { log( "Display resize can't happen now (state == DisplayStateResizeInProgress)", @@ -49,21 +43,23 @@ class DisplayCubit extends Cubit with Logger { return; } } - _startResize(viewSize: viewSize); + await _startResize(viewSize: viewSize); } - void onWindowSizeChanged(Size updatedSize) { + Future onWindowSizeChanged(Size updatedSize) async { log('`physicalSize`: $updatedSize'); - _startResize(viewSize: updatedSize); + await _startResize(viewSize: updatedSize); } - void onDisplayTypeSelectionFinished({required bool isPrimaryDisplay}) { + Future onDisplayTypeSelectionFinished({ + required bool isPrimaryDisplay, + }) async { emit(DisplayStateDisplayTypeSelectionFinished()); _repository.setDisplayType(isPrimaryDisplay); - _startResize(); + await _startResize(); } - void _startResize({Size viewSize = Size.zero}) async { + Future _startResize({Size viewSize = Size.zero}) async { if (state is DisplayStateResizeCoolDown) { final currentState = state as DisplayStateResizeCoolDown; if (currentState.viewSize == viewSize) { @@ -212,6 +208,8 @@ class DisplayCubit extends Cubit with Logger { logException(exception: exception, stackTrace: stacktrace); } } else { + _resizeCoolDownTimer?.cancel(); + _resizeCoolDownTimer = null; log("Unable to send resize request. Invalid state, no pending resize"); } } diff --git a/lib/feature/display/widget/display_view.dart b/lib/feature/display/widget/display_view.dart index 06e757e..05c828c 100644 --- a/lib/feature/display/widget/display_view.dart +++ b/lib/feature/display/widget/display_view.dart @@ -1,129 +1,2 @@ -import 'dart:convert'; -import 'dart:js_interop'; -import 'dart:ui_web' as ui; - -import 'package:flavor/flavor.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:tesla_android/common/di/ta_locator.dart'; -import 'package:tesla_android/common/utils/logger.dart'; -import 'package:tesla_android/feature/display/cubit/display_cubit.dart'; -import 'package:tesla_android/feature/display/cubit/display_state.dart'; -import 'package:tesla_android/feature/display/model/remote_display_state.dart'; -import 'package:tesla_android/feature/settings/bloc/audio_configuration_cubit.dart'; -import 'package:tesla_android/feature/settings/bloc/audio_configuration_state.dart'; -import 'package:tesla_android/feature/settings/bloc/gps_configuration_cubit.dart'; -import 'package:tesla_android/feature/settings/bloc/gps_configuration_state.dart'; -import 'package:web/web.dart' as web; - -class DisplayView extends StatefulWidget { - final DisplayRendererType type; - - const DisplayView({super.key, required this.type}); - - @override - State createState() => _IframeViewState(); -} - -class _IframeViewState extends State - with Logger { - static const String _src = "/android.html"; - - final web.HTMLIFrameElement _iframeElement = web.HTMLIFrameElement(); - - web.EventListener? _onMessageJs; - - @override - void initState() { - super.initState(); - - // Prepare the iframe element once. - _iframeElement.src = _src; - _iframeElement.style.border = 'none'; - - ui.platformViewRegistry.registerViewFactory( - _src, - (int viewId) => _iframeElement, - ); - } - - @override - void dispose() { - if (_onMessageJs != null) { - web.window.removeEventListener('message', _onMessageJs!); - _onMessageJs = null; - } - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return HtmlElementView( - viewType: _src, - onPlatformViewCreated: (_) { - // Wire a one-time message listener on window to catch "iframeReady" - // from android.html and then send the config payload. - _onMessageJs = (web.Event e) { - if (e is web.MessageEvent) { - final data = e.data; - // android.html posts "iframeReady" to parent on load - if (data is String && data == "iframeReady") { - _sendConfigToIframe(); - } - } - }.toJS; - - web.window.addEventListener('message', _onMessageJs); - }, - ); - } - - void _sendConfigToIframe() async { - final flavor = getIt(); - - final displayCubit = context.read(); - final gpsCubit = context.read(); - final audioCubit = context.read(); - - await gpsCubit.fetchConfiguration(); - await audioCubit.fetchConfiguration(); - - final displayState = displayCubit.state; - final gpsState = gpsCubit.state; - final audioState = audioCubit.state; - - if (displayState is! DisplayStateNormal) { - // If display state isn't ready yet, try again on next frame. - WidgetsBinding.instance.addPostFrameCallback((_) => _sendConfigToIframe()); - return; - } - - // Build config. (Audio values are harmless here; audio is handled in index.html.) - final config = { - 'audioWebsocketUrl': flavor.getString("audioWebSocket") ?? "", - 'displayWebsocketUrl': flavor.getString("displayWebSocket") ?? "", - 'gpsWebsocketUrl': flavor.getString("gpsWebSocket") ?? "", - 'touchScreenWebsocketUrl': flavor.getString("touchscreenWebSocket") ?? "", - 'isGPSEnabled': (gpsState is GPSConfigurationStateLoaded - ? gpsState.isGPSEnabled - : false) - .toString(), - 'isAudioEnabled': (audioState is AudioConfigurationStateSettingsFetched - ? audioState.isEnabled - : true) - .toString(), - 'audioVolume': (audioState is AudioConfigurationStateSettingsFetched - ? (audioState.volume / 100) - : 1.0) - .toStringAsFixed(2), - 'displayRenderer': widget.type.resourcePath(), - 'displayBinaryType': widget.type.binaryType(), - 'displayWidth': displayState.adjustedSize.width.toString(), - 'displayHeight': displayState.adjustedSize.height.toString(), - }; - - final cfgJson = jsonEncode(config).toJS; - web.window.postMessage(cfgJson, '*'.toJS); - _iframeElement.contentWindow?.postMessage(cfgJson, '*'.toJS); - } -} \ No newline at end of file +export 'display_view_stub.dart' + if (dart.library.js_interop) 'display_view_web.dart'; diff --git a/lib/feature/display/widget/display_view_stub.dart b/lib/feature/display/widget/display_view_stub.dart new file mode 100644 index 0000000..62f2253 --- /dev/null +++ b/lib/feature/display/widget/display_view_stub.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; +import 'package:tesla_android/feature/display/model/remote_display_state.dart'; + +class DisplayView extends StatelessWidget { + final DisplayRendererType type; + + const DisplayView({super.key, required this.type}); + + @override + Widget build(BuildContext context) { + return const Center( + child: Text("DisplayView is not supported on this platform"), + ); + } +} diff --git a/lib/feature/display/widget/display_view_web.dart b/lib/feature/display/widget/display_view_web.dart new file mode 100644 index 0000000..ec17a6e --- /dev/null +++ b/lib/feature/display/widget/display_view_web.dart @@ -0,0 +1,143 @@ +import 'dart:convert'; +import 'dart:js_interop'; +import 'dart:ui_web' as ui; + +import 'package:flavor/flavor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tesla_android/common/di/ta_locator.dart'; +import 'package:tesla_android/common/utils/logger.dart'; +import 'package:tesla_android/feature/display/cubit/display_cubit.dart'; +import 'package:tesla_android/feature/display/cubit/display_state.dart'; +import 'package:tesla_android/feature/display/model/remote_display_state.dart'; +import 'package:tesla_android/feature/settings/bloc/audio_configuration_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/audio_configuration_state.dart'; +import 'package:tesla_android/feature/settings/bloc/gps_configuration_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/gps_configuration_state.dart'; +import 'package:web/web.dart' as web; + +class DisplayView extends StatefulWidget { + final DisplayRendererType type; + + const DisplayView({super.key, required this.type}); + + @override + State createState() => _IframeViewState(); +} + +class _IframeViewState extends State with Logger { + static const String _src = "/android.html"; + + final web.HTMLIFrameElement _iframeElement = web.HTMLIFrameElement(); + + web.EventListener? _onMessageJs; + + @override + void initState() { + super.initState(); + + // Prepare the iframe element once. + _iframeElement.src = _src; + _iframeElement.style.border = 'none'; + _iframeElement.style.width = '100%'; + _iframeElement.style.height = '100%'; + + ui.platformViewRegistry.registerViewFactory( + _src, + (int viewId) => _iframeElement, + ); + } + + @override + void dispose() { + if (_onMessageJs != null) { + web.window.removeEventListener('message', _onMessageJs!); + _onMessageJs = null; + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return HtmlElementView( + viewType: _src, + onPlatformViewCreated: (_) { + // Wire a one-time message listener on window to catch "iframeReady" + // from android.html and then send the config payload. + _onMessageJs = (web.Event e) { + final messageEvent = e as web.MessageEvent; + final data = messageEvent.data; + // android.html posts "iframeReady" to parent on load + if (data != null) { + final dataStr = (data as JSString).toDart; + if (dataStr == "iframeReady") { + _sendConfigToIframe(); + } + } + }.toJS; + + web.window.addEventListener('message', _onMessageJs); + }, + ); + } + + void _sendConfigToIframe() async { + final flavor = getIt(); + + if (!mounted) return; + + final displayCubit = context.read(); + final gpsCubit = context.read(); + final audioCubit = context.read(); + + await gpsCubit.fetchConfiguration(); + await audioCubit.fetchConfiguration(); + + if (!mounted) return; + + final displayState = displayCubit.state; + final gpsState = gpsCubit.state; + final audioState = audioCubit.state; + + if (displayState is! DisplayStateNormal) { + if (mounted) { + // If display state isn't ready yet, try again on next frame. + WidgetsBinding.instance.addPostFrameCallback( + (_) => _sendConfigToIframe(), + ); + } + return; + } + + // Build config. (Audio values are harmless here; audio is handled in index.html.) + final config = { + 'audioWebsocketUrl': flavor.getString("audioWebSocket") ?? "", + 'displayWebsocketUrl': flavor.getString("displayWebSocket") ?? "", + 'gpsWebsocketUrl': flavor.getString("gpsWebSocket") ?? "", + 'touchScreenWebsocketUrl': flavor.getString("touchscreenWebSocket") ?? "", + 'isGPSEnabled': + (gpsState is GPSConfigurationStateLoaded + ? gpsState.isGPSEnabled + : false) + .toString(), + 'isAudioEnabled': + (audioState is AudioConfigurationStateSettingsFetched + ? audioState.isEnabled + : true) + .toString(), + 'audioVolume': + (audioState is AudioConfigurationStateSettingsFetched + ? (audioState.volume / 100) + : 1.0) + .toStringAsFixed(2), + 'displayRenderer': widget.type.resourcePath(), + 'displayBinaryType': widget.type.binaryType(), + 'displayWidth': displayState.adjustedSize.width.toString(), + 'displayHeight': displayState.adjustedSize.height.toString(), + }; + + final cfgJson = jsonEncode(config).toJS; + web.window.postMessage(cfgJson, '*'.toJS); + _iframeElement.contentWindow?.postMessage(cfgJson, '*'.toJS); + } +} diff --git a/lib/feature/donations/widget/donation_page.dart b/lib/feature/donations/widget/donation_page.dart index 008b055..61a7981 100644 --- a/lib/feature/donations/widget/donation_page.dart +++ b/lib/feature/donations/widget/donation_page.dart @@ -11,9 +11,7 @@ class DonationPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: TaAppBar( - title: TAPage.donations.title, - ), + appBar: TaAppBar(title: TAPage.donations.title), bottomNavigationBar: const TaBottomNavigationBar(currentIndex: 3), body: Center( child: Column( @@ -26,16 +24,14 @@ class DonationPage extends StatelessWidget { textAlign: TextAlign.center, ), ), - const SizedBox( - height: TADimens.baseContentMargin, - ), + const SizedBox(height: TADimens.baseContentMargin), SizedBox( width: TADimens.donationQRSize, height: TADimens.donationQRSize, child: QrImageView( data: "https://teslaandroid.com/donations", version: QrVersions.auto, - ) + ), ), ], ), diff --git a/lib/feature/home/cubit/ota_update_cubit.dart b/lib/feature/home/cubit/ota_update_cubit.dart index 6acef97..1399fe7 100644 --- a/lib/feature/home/cubit/ota_update_cubit.dart +++ b/lib/feature/home/cubit/ota_update_cubit.dart @@ -17,19 +17,19 @@ class OTAUpdateCubit extends Cubit with Logger { final GitHubReleaseRepository _repository; final SharedPreferences _sharedPreferences; - OTAUpdateCubit( - this._repository, - this._sharedPreferences, - ) : super(OTAUpdateStateInitial()); + OTAUpdateCubit(this._repository, this._sharedPreferences) + : super(OTAUpdateStateInitial()); Future checkForUpdates() async { - final lastCheckedTimestamp = _sharedPreferences.getInt(_lastCheckedKey) ?? 0; + final lastCheckedTimestamp = + _sharedPreferences.getInt(_lastCheckedKey) ?? 0; final currentTime = DateTime.now().millisecondsSinceEpoch; final packageInfo = await PackageInfo.fromPlatform(); final storedVersion = _sharedPreferences.getString(_lastVersionKey) ?? ''; - if (currentTime - lastCheckedTimestamp < 6 * 60 * 60 * 1000 && storedVersion == packageInfo.version) { + if (currentTime - lastCheckedTimestamp < 6 * 60 * 60 * 1000 && + storedVersion == packageInfo.version) { if (_sharedPreferences.getBool(_updateAvailableKey) == true) { emit(OTAUpdateStateAvailable()); } else { @@ -40,8 +40,10 @@ class OTAUpdateCubit extends Cubit with Logger { try { final latestVersion = await _repository.getLatestRelease(); - final areUpdatesAvailable = - _checkIfUpdateIsAvailable(packageInfo.version, latestVersion.name); + final areUpdatesAvailable = _checkIfUpdateIsAvailable( + packageInfo.version, + latestVersion.name, + ); _sharedPreferences.setBool(_updateAvailableKey, areUpdatesAvailable); _sharedPreferences.setInt(_lastCheckedKey, currentTime); @@ -53,10 +55,7 @@ class OTAUpdateCubit extends Cubit with Logger { emit(OTAUpdateStateNotAvailable()); } } catch (exception, stacktrace) { - logException( - exception: exception, - stackTrace: stacktrace, - ); + logException(exception: exception, stackTrace: stacktrace); await Future.delayed(const Duration(minutes: 5), () { checkForUpdates(); }); @@ -64,7 +63,7 @@ class OTAUpdateCubit extends Cubit with Logger { } void launchUpdater() { - _repository.openUpdater(); + _repository.openUpdater(); } bool _checkIfUpdateIsAvailable(String currentVersion, String latestRelease) { diff --git a/lib/feature/home/cubit/ota_update_state.dart b/lib/feature/home/cubit/ota_update_state.dart index 59aac2a..5adbd0b 100644 --- a/lib/feature/home/cubit/ota_update_state.dart +++ b/lib/feature/home/cubit/ota_update_state.dart @@ -4,4 +4,4 @@ class OTAUpdateStateInitial extends OTAUpdateState {} class OTAUpdateStateAvailable extends OTAUpdateState {} -class OTAUpdateStateNotAvailable extends OTAUpdateState {} \ No newline at end of file +class OTAUpdateStateNotAvailable extends OTAUpdateState {} diff --git a/lib/feature/home/home_page.dart b/lib/feature/home/home_page.dart index 8f88f60..100b94a 100644 --- a/lib/feature/home/home_page.dart +++ b/lib/feature/home/home_page.dart @@ -45,7 +45,9 @@ class HomePage extends StatelessWidget { builder: (context, state) { if (state is DisplayStateNormal) { return AspectRatio( - aspectRatio: state.adjustedSize.width / state.adjustedSize.height, + aspectRatio: + state.adjustedSize.width / + state.adjustedSize.height, child: Stack( fit: StackFit.expand, children: [ @@ -55,7 +57,9 @@ class HomePage extends StatelessWidget { type: state.rendererType, ), PointerInterceptor( - child: TouchScreenView(displaySize: state.adjustedSize), + child: TouchScreenView( + displaySize: state.adjustedSize, + ), ), ], ), @@ -73,7 +77,7 @@ class HomePage extends StatelessWidget { top: 0, child: Container( decoration: BoxDecoration( - color: Colors.black.withOpacity(0.8), + color: Colors.black.withValues(alpha: 0.5), borderRadius: const BorderRadius.only( bottomLeft: Radius.circular( TADimens.ROUND_BORDER_RADIUS, @@ -81,7 +85,11 @@ class HomePage extends StatelessWidget { ), ), child: const Row( - children: [AudioButton(), UpdateButton(), SettingsButton()], + children: [ + AudioButton(), + UpdateButton(), + SettingsButton(), + ], ), ), ), diff --git a/lib/feature/home/model/github_release.dart b/lib/feature/home/model/github_release.dart index c6287d5..42034c4 100644 --- a/lib/feature/home/model/github_release.dart +++ b/lib/feature/home/model/github_release.dart @@ -7,9 +7,7 @@ part 'github_release.g.dart'; class GitHubRelease extends Equatable { final String name; - const GitHubRelease({ - required this.name, - }); + const GitHubRelease({required this.name}); factory GitHubRelease.fromJson(Map json) => _$GitHubReleaseFromJson(json); @@ -17,7 +15,5 @@ class GitHubRelease extends Equatable { Map toJson() => _$GitHubReleaseToJson(this); @override - List get props => [ - name, - ]; + List get props => [name]; } diff --git a/lib/feature/home/repository/github_release_repository.dart b/lib/feature/home/repository/github_release_repository.dart index c945fa6..f1d8069 100644 --- a/lib/feature/home/repository/github_release_repository.dart +++ b/lib/feature/home/repository/github_release_repository.dart @@ -8,10 +8,7 @@ class GitHubReleaseRepository { final GitHubService _service; final DeviceInfoService _deviceInfoService; - GitHubReleaseRepository( - this._service, - this._deviceInfoService, - ); + GitHubReleaseRepository(this._service, this._deviceInfoService); Future getLatestRelease() { return _service.getLatestRelease(); diff --git a/lib/feature/home/widget/audio_button.dart b/lib/feature/home/widget/audio_button.dart index 07790bc..efdfc62 100644 --- a/lib/feature/home/widget/audio_button.dart +++ b/lib/feature/home/widget/audio_button.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:get_it/get_it.dart'; +import 'package:tesla_android/common/service/audio_service.dart'; import 'package:tesla_android/common/ui/constants/ta_dimens.dart'; -import 'package:tesla_android/common/utils/audio_api.dart'; import 'package:tesla_android/common/utils/logger.dart'; import 'package:tesla_android/feature/settings/bloc/audio_configuration_cubit.dart'; @@ -17,12 +18,13 @@ class AudioButton extends StatefulWidget { class _AudioButtonState extends State with Logger { String _state = 'stopped'; VoidCallback? _removeListener; + final AudioService _audioService = GetIt.I(); @override void initState() { super.initState(); _readInitialState(); - _removeListener = addAudioStateListener((s) { + _removeListener = _audioService.addAudioStateListener((s) { if (!mounted) return; setState(() => _state = s); }); @@ -36,7 +38,7 @@ class _AudioButtonState extends State with Logger { void _readInitialState() { try { - _state = getAudioState(); + _state = _audioService.getAudioState(); } catch (_) { _state = 'stopped'; } @@ -44,10 +46,10 @@ class _AudioButtonState extends State with Logger { void _onPressed() { if (_state == 'playing') { - stopAudio(); + _audioService.stopAudio(); setState(() => _state = 'stopped'); } else { - startAudioFromGesture(); + _audioService.startAudioFromGesture(); setState(() => _state = 'playing'); } } @@ -60,7 +62,8 @@ class _AudioButtonState extends State with Logger { bloc: BlocProvider.of(context) ..fetchConfiguration(), builder: (context, audioState) { - if (audioState is AudioConfigurationStateSettingsFetched && audioState.isEnabled) { + if (audioState is AudioConfigurationStateSettingsFetched && + audioState.isEnabled) { return IconButton( color: Colors.white, onPressed: _onPressed, diff --git a/lib/feature/home/widget/display_size_watcher.dart b/lib/feature/home/widget/display_size_watcher.dart index 6d535f4..ec013be 100644 --- a/lib/feature/home/widget/display_size_watcher.dart +++ b/lib/feature/home/widget/display_size_watcher.dart @@ -31,4 +31,4 @@ class _DisplaySizeWatcherState extends State { }, ); } -} \ No newline at end of file +} diff --git a/lib/feature/home/widget/settings_button.dart b/lib/feature/home/widget/settings_button.dart index ade82ac..9d4a365 100644 --- a/lib/feature/home/widget/settings_button.dart +++ b/lib/feature/home/widget/settings_button.dart @@ -12,15 +12,9 @@ class SettingsButton extends StatelessWidget with Logger { return IconButton( color: Colors.white, onPressed: () { - TANavigator.pushReplacement( - context: context, - page: TAPage.about, - ); + TANavigator.pushReplacement(context: context, page: TAPage.about); }, - icon: const Icon( - Icons.settings, - size: TADimens.statusBarIconSize, - ), + icon: const Icon(Icons.settings, size: TADimens.statusBarIconSize), ); } } diff --git a/lib/feature/home/widget/update_button.dart b/lib/feature/home/widget/update_button.dart index 3fda75a..03f0b1a 100644 --- a/lib/feature/home/widget/update_button.dart +++ b/lib/feature/home/widget/update_button.dart @@ -11,21 +11,22 @@ class UpdateButton extends StatelessWidget with Logger { @override Widget build(BuildContext context) { return BlocBuilder( - builder: (context, state) { - if (state is OTAUpdateStateAvailable) { - return IconButton( - color: Colors.amber, - onPressed: () { - BlocProvider.of(context).launchUpdater(); - }, - icon: const Icon( - Icons.download_rounded, - size: TADimens.statusBarIconSize, - ), - ); - } else { - return const SizedBox(); - } - }); + builder: (context, state) { + if (state is OTAUpdateStateAvailable) { + return IconButton( + color: Colors.amber, + onPressed: () { + BlocProvider.of(context).launchUpdater(); + }, + icon: const Icon( + Icons.download_rounded, + size: TADimens.statusBarIconSize, + ), + ); + } else { + return const SizedBox(); + } + }, + ); } } diff --git a/lib/feature/releaseNotes/cubit/release_notes_cubit.dart b/lib/feature/releaseNotes/cubit/release_notes_cubit.dart index ebb3ba5..0742c1e 100644 --- a/lib/feature/releaseNotes/cubit/release_notes_cubit.dart +++ b/lib/feature/releaseNotes/cubit/release_notes_cubit.dart @@ -27,10 +27,13 @@ class ReleaseNotesCubit extends Cubit { }) { if (state is ReleaseNotesStateLoaded) { final releaseNotes = (state as ReleaseNotesStateLoaded).releaseNotes; - emit(ReleaseNotesStateLoaded.withSelection( + emit( + ReleaseNotesStateLoaded.withSelection( releaseNotes: releaseNotes, selectedVersion: version, - selectedChangelogItem: changelogItem)); + selectedChangelogItem: changelogItem, + ), + ); } } } diff --git a/lib/feature/releaseNotes/cubit/release_notes_state.dart b/lib/feature/releaseNotes/cubit/release_notes_state.dart index 65b1e12..57e87ca 100644 --- a/lib/feature/releaseNotes/cubit/release_notes_state.dart +++ b/lib/feature/releaseNotes/cubit/release_notes_state.dart @@ -15,11 +15,9 @@ class ReleaseNotesStateLoaded extends ReleaseNotesState { final Version selectedVersion; final ChangelogItem selectedChangelogItem; - ReleaseNotesStateLoaded({ - required this.releaseNotes, - }) : selectedVersion = releaseNotes.versions.first, - selectedChangelogItem = - releaseNotes.versions.first.changelogItems.first; + ReleaseNotesStateLoaded({required this.releaseNotes}) + : selectedVersion = releaseNotes.versions.first, + selectedChangelogItem = releaseNotes.versions.first.changelogItems.first; ReleaseNotesStateLoaded.withSelection({ required this.releaseNotes, diff --git a/lib/feature/releaseNotes/model/changelog_item.dart b/lib/feature/releaseNotes/model/changelog_item.dart index a42a666..aca7884 100644 --- a/lib/feature/releaseNotes/model/changelog_item.dart +++ b/lib/feature/releaseNotes/model/changelog_item.dart @@ -21,9 +21,5 @@ class ChangelogItem extends Equatable { Map toJson() => _$ChangelogItemToJson(this); @override - List get props => [ - title, - shortDescription, - descriptionMarkdown, - ]; + List get props => [title, shortDescription, descriptionMarkdown]; } diff --git a/lib/feature/releaseNotes/model/release_notes.dart b/lib/feature/releaseNotes/model/release_notes.dart index 0c79101..7fc37a3 100644 --- a/lib/feature/releaseNotes/model/release_notes.dart +++ b/lib/feature/releaseNotes/model/release_notes.dart @@ -7,9 +7,7 @@ part 'release_notes.g.dart'; class ReleaseNotes { final List versions; - const ReleaseNotes({ - required this.versions, - }); + const ReleaseNotes({required this.versions}); factory ReleaseNotes.fromJson(Map json) => _$ReleaseNotesFromJson(json); diff --git a/lib/feature/releaseNotes/model/version.dart b/lib/feature/releaseNotes/model/version.dart index e4e7de4..aca9ce1 100644 --- a/lib/feature/releaseNotes/model/version.dart +++ b/lib/feature/releaseNotes/model/version.dart @@ -8,10 +8,7 @@ class Version { final String versionName; final List changelogItems; - const Version({ - required this.versionName, - required this.changelogItems, - }); + const Version({required this.versionName, required this.changelogItems}); factory Version.fromJson(Map json) => _$VersionFromJson(json); diff --git a/lib/feature/releaseNotes/repository/release_notes_repository.dart b/lib/feature/releaseNotes/repository/release_notes_repository.dart index 5a2db27..d3e8e22 100644 --- a/lib/feature/releaseNotes/repository/release_notes_repository.dart +++ b/lib/feature/releaseNotes/repository/release_notes_repository.dart @@ -20,7 +20,7 @@ class ReleaseNotesRepository { title: "Real Time Clock", shortDescription: "Hardware improvements", descriptionMarkdown: - "Tesla Android (2nd generation) includes a Real Time Clock module.\n\nThis ensures time is preserved across reboots even if your device is offline (this improves boot time, since incorrect time can lead to SSL errors when loading the page).", + "Tesla Android (2nd generation) includes a Real Time Clock module.\n\nThis ensures time is preserved across reboots even if your device is offline (this improves boot time, since incorrect time can lead to SSL errors when loading the page).", ), ], ), diff --git a/lib/feature/releaseNotes/widget/card/release_notes_changelog_item_card.dart b/lib/feature/releaseNotes/widget/card/release_notes_changelog_item_card.dart index 68d2aab..3b68037 100644 --- a/lib/feature/releaseNotes/widget/card/release_notes_changelog_item_card.dart +++ b/lib/feature/releaseNotes/widget/card/release_notes_changelog_item_card.dart @@ -38,13 +38,8 @@ class ReleaseNotesChangelogItemCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(changelogItem.title, style: textTheme.labelLarge), - const SizedBox( - height: TADimens.PADDING_XS_VALUE, - ), - Text( - changelogItem.shortDescription, - style: textTheme.bodySmall, - ), + const SizedBox(height: TADimens.PADDING_XS_VALUE), + Text(changelogItem.shortDescription, style: textTheme.bodySmall), ], ), ), @@ -60,7 +55,7 @@ class ReleaseNotesChangelogItemCard extends StatelessWidget { if (isDark) { return isActive ? themeCardColor : Colors.transparent; } else { - return isActive ? themeCardColor.withOpacity(0.75) : themeCardColor; + return isActive ? Colors.white.withValues(alpha: 0.5) : themeCardColor; } } } diff --git a/lib/feature/releaseNotes/widget/list/release_notes_versions_list.dart b/lib/feature/releaseNotes/widget/list/release_notes_versions_list.dart index f7fe475..09214c1 100644 --- a/lib/feature/releaseNotes/widget/list/release_notes_versions_list.dart +++ b/lib/feature/releaseNotes/widget/list/release_notes_versions_list.dart @@ -25,10 +25,7 @@ class ReleaseNotesVersionList extends StatelessWidget { ); } - Widget _versionItemBuilder( - BuildContext context, - int index, - ) { + Widget _versionItemBuilder(BuildContext context, int index) { final version = versions[index]; final textTheme = Theme.of(context).textTheme; return Column( @@ -37,10 +34,7 @@ class ReleaseNotesVersionList extends StatelessWidget { children: [ Padding( padding: TADimens.basePaddingVertical, - child: Text( - version.versionName, - style: textTheme.titleMedium, - ), + child: Text(version.versionName, style: textTheme.titleMedium), ), ...version.changelogItems.map( (e) => ReleaseNotesChangelogItemCard( diff --git a/lib/feature/releaseNotes/widget/release_notes_page.dart b/lib/feature/releaseNotes/widget/release_notes_page.dart index 3717850..b53de6a 100644 --- a/lib/feature/releaseNotes/widget/release_notes_page.dart +++ b/lib/feature/releaseNotes/widget/release_notes_page.dart @@ -20,14 +20,10 @@ class ReleaseNotesPage extends StatelessWidget { BlocProvider.of(context).loadReleaseNotes(); return Scaffold( - appBar: TaAppBar( - title: TAPage.releaseNotes.title, - ), - bottomNavigationBar: const TaBottomNavigationBar( - currentIndex: 2, - ), - body: BlocBuilder( - builder: (context, state) { + appBar: TaAppBar(title: TAPage.releaseNotes.title), + bottomNavigationBar: const TaBottomNavigationBar(currentIndex: 2), + body: BlocBuilder( + builder: (context, state) { if (state is ReleaseNotesStateLoading) { return _loadingStateWidget(); } else if (state is ReleaseNotesStateUnavailable) { @@ -37,22 +33,25 @@ class ReleaseNotesPage extends StatelessWidget { } else { return Container(); } - })); + }, + ), + ); } Widget _loadingStateWidget() { - return const Center( - child: CircularProgressIndicator(), - ); + return const Center(child: CircularProgressIndicator()); } Widget _errorStateWidget() { return const Center( - child: Text("Error loading release notes. Please try again later.")); + child: Text("Error loading release notes. Please try again later."), + ); } Widget _loadedStateWidget( - BuildContext context, ReleaseNotesStateLoaded state) { + BuildContext context, + ReleaseNotesStateLoaded state, + ) { final ReleaseNotes releaseNotes = state.releaseNotes; final Version selectedVersion = state.selectedVersion; final ChangelogItem selectedChangelogItem = state.selectedChangelogItem; @@ -70,8 +69,9 @@ class ReleaseNotesPage extends StatelessWidget { ), Expanded( child: ReleaseNotesChangelogItemDetailsView( - changelogItem: selectedChangelogItem), - ) + changelogItem: selectedChangelogItem, + ), + ), ], ); } diff --git a/lib/feature/settings/bloc/audio_configuration_cubit.dart b/lib/feature/settings/bloc/audio_configuration_cubit.dart index f8622d0..1011dca 100644 --- a/lib/feature/settings/bloc/audio_configuration_cubit.dart +++ b/lib/feature/settings/bloc/audio_configuration_cubit.dart @@ -10,7 +10,7 @@ class AudioConfigurationCubit extends Cubit final SystemConfigurationRepository _repository; AudioConfigurationCubit(this._repository) - : super(AudioConfigurationStateInitial()); + : super(AudioConfigurationStateInitial()); Future fetchConfiguration() async { if (!isClosed) emit(AudioConfigurationStateLoading()); @@ -18,18 +18,14 @@ class AudioConfigurationCubit extends Cubit final configuration = await _repository.getConfiguration(); emit( AudioConfigurationStateSettingsFetched( - isEnabled: configuration.browserAudioIsEnabled == 1, - volume: configuration.browserAudioVolume), + isEnabled: configuration.browserAudioIsEnabled == 1, + volume: configuration.browserAudioVolume, + ), ); } catch (exception, stacktrace) { - logException( - exception: exception, - stackTrace: stacktrace, - ); + logException(exception: exception, stackTrace: stacktrace); if (!isClosed) { - emit( - AudioConfigurationStateError(), - ); + emit(AudioConfigurationStateError()); } } } @@ -42,12 +38,13 @@ class AudioConfigurationCubit extends Cubit if (!isClosed) { emit( AudioConfigurationStateSettingsFetched( - isEnabled: true, volume: newVolume), + isEnabled: true, + volume: newVolume, + ), ); } } catch (exception, stackTrace) { - logException( - exception: exception, stackTrace: stackTrace); + logException(exception: exception, stackTrace: stackTrace); if (!isClosed) emit(AudioConfigurationStateError()); } } @@ -60,12 +57,13 @@ class AudioConfigurationCubit extends Cubit if (!isClosed) { emit( AudioConfigurationStateSettingsFetched( - isEnabled: isEnabled, volume: 100), + isEnabled: isEnabled, + volume: 100, + ), ); } } catch (exception, stackTrace) { - logException( - exception: exception, stackTrace: stackTrace); + logException(exception: exception, stackTrace: stackTrace); if (!isClosed) emit(AudioConfigurationStateError()); } } diff --git a/lib/feature/settings/bloc/audio_configuration_state.dart b/lib/feature/settings/bloc/audio_configuration_state.dart index 4e40daf..80ad682 100644 --- a/lib/feature/settings/bloc/audio_configuration_state.dart +++ b/lib/feature/settings/bloc/audio_configuration_state.dart @@ -4,8 +4,7 @@ class AudioConfigurationStateInitial extends AudioConfigurationState {} class AudioConfigurationStateLoading extends AudioConfigurationState {} -class AudioConfigurationStateSettingsFetched - extends AudioConfigurationState { +class AudioConfigurationStateSettingsFetched extends AudioConfigurationState { final bool isEnabled; final int volume; diff --git a/lib/feature/settings/bloc/device_info_cubit.dart b/lib/feature/settings/bloc/device_info_cubit.dart index 175a9e7..ebee2a0 100644 --- a/lib/feature/settings/bloc/device_info_cubit.dart +++ b/lib/feature/settings/bloc/device_info_cubit.dart @@ -5,30 +5,20 @@ import 'package:tesla_android/feature/settings/bloc/device_info_state.dart'; import 'package:tesla_android/feature/settings/repository/device_info_repository.dart'; @injectable -class DeviceInfoCubit extends Cubit - with Logger { +class DeviceInfoCubit extends Cubit with Logger { final DeviceInfoRepository _repository; - DeviceInfoCubit(this._repository) - : super(DeviceInfoStateInitial()); + DeviceInfoCubit(this._repository) : super(DeviceInfoStateInitial()); void fetchConfiguration() async { if (!isClosed) emit(DeviceInfoStateLoading()); try { final healthState = await _repository.getDeviceInfo(); - emit( - DeviceInfoStateLoaded( - deviceInfo: healthState), - ); + emit(DeviceInfoStateLoaded(deviceInfo: healthState)); } catch (exception, stacktrace) { - logException( - exception: exception, - stackTrace: stacktrace, - ); + logException(exception: exception, stackTrace: stacktrace); if (!isClosed) { - emit( - DeviceInfoStateError(), - ); + emit(DeviceInfoStateError()); } } } diff --git a/lib/feature/settings/bloc/device_info_state.dart b/lib/feature/settings/bloc/device_info_state.dart index e022809..32ec59d 100644 --- a/lib/feature/settings/bloc/device_info_state.dart +++ b/lib/feature/settings/bloc/device_info_state.dart @@ -6,12 +6,10 @@ class DeviceInfoStateInitial extends DeviceInfoState {} class DeviceInfoStateLoading extends DeviceInfoState {} -class DeviceInfoStateLoaded - extends DeviceInfoState { +class DeviceInfoStateLoaded extends DeviceInfoState { final DeviceInfo deviceInfo; - DeviceInfoStateLoaded({ - required this.deviceInfo, - }); + DeviceInfoStateLoaded({required this.deviceInfo}); } + class DeviceInfoStateError extends DeviceInfoState {} diff --git a/lib/feature/settings/bloc/display_configuration_cubit.dart b/lib/feature/settings/bloc/display_configuration_cubit.dart index cea19d1..982455c 100644 --- a/lib/feature/settings/bloc/display_configuration_cubit.dart +++ b/lib/feature/settings/bloc/display_configuration_cubit.dart @@ -13,7 +13,7 @@ class DisplayConfigurationCubit extends Cubit RemoteDisplayState? _currentConfig; DisplayConfigurationCubit(this._repository) - : super(DisplayConfigurationStateInitial()); + : super(DisplayConfigurationStateInitial()); void fetchConfiguration() async { if (!isClosed) emit(DisplayConfigurationStateLoading()); @@ -21,14 +21,9 @@ class DisplayConfigurationCubit extends Cubit _currentConfig = await _repository.getDisplayState(); _emitCurrentConfig(); } catch (exception, stacktrace) { - logException( - exception: exception, - stackTrace: stacktrace, - ); + logException(exception: exception, stackTrace: stacktrace); if (!isClosed) { - emit( - DisplayConfigurationStateError(), - ); + emit(DisplayConfigurationStateError()); } } } @@ -126,13 +121,15 @@ class DisplayConfigurationCubit extends Cubit void _emitCurrentConfig() { if (!isClosed) { - emit(DisplayConfigurationStateSettingsFetched( - resolutionPreset: _currentConfig!.resolutionPreset, - renderer: _currentConfig!.renderer, - isResponsive: _currentConfig!.isResponsive == 1, - refreshRate: _currentConfig!.refreshRate, - quality: _currentConfig!.quality, - )); + emit( + DisplayConfigurationStateSettingsFetched( + resolutionPreset: _currentConfig!.resolutionPreset, + renderer: _currentConfig!.renderer, + isResponsive: _currentConfig!.isResponsive == 1, + refreshRate: _currentConfig!.refreshRate, + quality: _currentConfig!.quality, + ), + ); } } } diff --git a/lib/feature/settings/bloc/gps_configuration_cubit.dart b/lib/feature/settings/bloc/gps_configuration_cubit.dart index fa6babc..b14b353 100644 --- a/lib/feature/settings/bloc/gps_configuration_cubit.dart +++ b/lib/feature/settings/bloc/gps_configuration_cubit.dart @@ -9,7 +9,7 @@ class GPSConfigurationCubit extends Cubit with Logger { final SystemConfigurationRepository _configurationRepository; GPSConfigurationCubit(this._configurationRepository) - : super(GPSConfigurationStateInitial()); + : super(GPSConfigurationStateInitial()); Future fetchConfiguration() async { if (!isClosed) emit(GPSConfigurationStateLoading()); @@ -17,17 +17,13 @@ class GPSConfigurationCubit extends Cubit with Logger { final configuration = await _configurationRepository.getConfiguration(); emit( GPSConfigurationStateLoaded( - isGPSEnabled: configuration.isGPSEnabled == 1), + isGPSEnabled: configuration.isGPSEnabled == 1, + ), ); } catch (exception, stacktrace) { - logException( - exception: exception, - stackTrace: stacktrace, - ); + logException(exception: exception, stackTrace: stacktrace); if (!isClosed) { - emit( - GPSConfigurationStateError(), - ); + emit(GPSConfigurationStateError()); } } } @@ -38,10 +34,7 @@ class GPSConfigurationCubit extends Cubit with Logger { await _configurationRepository.setGPSState(newState == true ? 1 : 0); emit(GPSConfigurationStateLoaded(isGPSEnabled: newState)); } catch (exception, stacktrace) { - logException( - exception: exception, - stackTrace: stacktrace, - ); + logException(exception: exception, stackTrace: stacktrace); emit(GPSConfigurationStateError()); } } diff --git a/lib/feature/settings/bloc/gps_configuration_state.dart b/lib/feature/settings/bloc/gps_configuration_state.dart index cdf1456..b9a29b3 100644 --- a/lib/feature/settings/bloc/gps_configuration_state.dart +++ b/lib/feature/settings/bloc/gps_configuration_state.dart @@ -4,22 +4,16 @@ class GPSConfigurationStateInitial extends GPSConfigurationState {} class GPSConfigurationStateLoading extends GPSConfigurationState {} -class GPSConfigurationStateLoaded - extends GPSConfigurationState { +class GPSConfigurationStateLoaded extends GPSConfigurationState { final bool isGPSEnabled; - GPSConfigurationStateLoaded({ - required this.isGPSEnabled, - }); + GPSConfigurationStateLoaded({required this.isGPSEnabled}); } -class GPSConfigurationStateUpdateInProgress - extends GPSConfigurationState { +class GPSConfigurationStateUpdateInProgress extends GPSConfigurationState { final bool isGPSEnabled; - GPSConfigurationStateUpdateInProgress({ - required this.isGPSEnabled, - }); + GPSConfigurationStateUpdateInProgress({required this.isGPSEnabled}); } class GPSConfigurationStateError extends GPSConfigurationState {} diff --git a/lib/feature/settings/bloc/rear_display_configuration_cubit.dart b/lib/feature/settings/bloc/rear_display_configuration_cubit.dart index 598ccbc..8effe9b 100644 --- a/lib/feature/settings/bloc/rear_display_configuration_cubit.dart +++ b/lib/feature/settings/bloc/rear_display_configuration_cubit.dart @@ -82,13 +82,17 @@ class RearDisplayConfigurationCubit extends Cubit } void _emitCurrentConfig() async { + if (isClosed) return; + final isCurrentDisplayPrimary = await _repository.isPrimaryDisplay(); if (!isClosed) { - final isCurrentDisplayPrimary = await _repository.isPrimaryDisplay(); - emit(RearDisplayConfigurationStateSettingsFetched( - isRearDisplayEnabled: _currentConfig!.isRearDisplayEnabled == 1, - isRearDisplayPrioritised: _currentConfig!.isRearDisplayPrioritised == 1, - isCurrentDisplayPrimary: isCurrentDisplayPrimary ?? true, - )); + emit( + RearDisplayConfigurationStateSettingsFetched( + isRearDisplayEnabled: _currentConfig!.isRearDisplayEnabled == 1, + isRearDisplayPrioritised: + _currentConfig!.isRearDisplayPrioritised == 1, + isCurrentDisplayPrimary: isCurrentDisplayPrimary ?? true, + ), + ); } } } diff --git a/lib/feature/settings/bloc/rear_display_configuration_state.dart b/lib/feature/settings/bloc/rear_display_configuration_state.dart index dad8b79..02f3e8e 100644 --- a/lib/feature/settings/bloc/rear_display_configuration_state.dart +++ b/lib/feature/settings/bloc/rear_display_configuration_state.dart @@ -1,4 +1,3 @@ - abstract class RearDisplayConfigurationState {} class RearDisplayConfigurationStateInitial diff --git a/lib/feature/settings/bloc/system_configuration_cubit.dart b/lib/feature/settings/bloc/system_configuration_cubit.dart index 9c8b9ce..617d351 100644 --- a/lib/feature/settings/bloc/system_configuration_cubit.dart +++ b/lib/feature/settings/bloc/system_configuration_cubit.dart @@ -1,4 +1,3 @@ -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:injectable/injectable.dart'; import 'package:tesla_android/common/utils/logger.dart'; @@ -10,20 +9,21 @@ import 'package:tesla_android/feature/settings/repository/system_configuration_r class SystemConfigurationCubit extends Cubit with Logger { final SystemConfigurationRepository _repository; - final GlobalKey _navigatorState; - SystemConfigurationCubit(this._repository, this._navigatorState) - : super(SystemConfigurationStateInitial()); + SystemConfigurationCubit(this._repository) + : super(SystemConfigurationStateInitial()); Future fetchConfiguration() async { emit(SystemConfigurationStateLoading()); try { final configuration = await _repository.getConfiguration(); - emit(SystemConfigurationStateSettingsFetched( - currentConfiguration: configuration)); + emit( + SystemConfigurationStateSettingsFetched( + currentConfiguration: configuration, + ), + ); } catch (exception, stackTrace) { - logException( - exception: exception, stackTrace: stackTrace); + logException(exception: exception, stackTrace: stackTrace); if (!isClosed) emit(SystemConfigurationStateSettingsFetchingError()); } } @@ -31,8 +31,11 @@ class SystemConfigurationCubit extends Cubit void updateSoftApBand(SoftApBandType newBand) { if (state is SystemConfigurationStateSettingsModified) { if (!isClosed) { - emit((state as SystemConfigurationStateSettingsModified) - .copyWith(newBandType: newBand)); + emit( + (state as SystemConfigurationStateSettingsModified).copyWith( + newBandType: newBand, + ), + ); } } if (state is SystemConfigurationStateSettingsFetched) { @@ -48,14 +51,16 @@ class SystemConfigurationCubit extends Cubit ); } } - showConfigurationChangedBanner(); } void updateSoftApState(bool isEnabled) { if (state is SystemConfigurationStateSettingsModified) { if (!isClosed) { - emit((state as SystemConfigurationStateSettingsModified) - .copyWith(isSoftApEnabled: isEnabled)); + emit( + (state as SystemConfigurationStateSettingsModified).copyWith( + isSoftApEnabled: isEnabled, + ), + ); } } if (state is SystemConfigurationStateSettingsFetched) { @@ -72,14 +77,16 @@ class SystemConfigurationCubit extends Cubit ); } } - showConfigurationChangedBanner(); } void updateOfflineModeState(bool isEnabled) { if (state is SystemConfigurationStateSettingsModified) { if (!isClosed) { - emit((state as SystemConfigurationStateSettingsModified) - .copyWith(isOfflineModeEnabled: isEnabled)); + emit( + (state as SystemConfigurationStateSettingsModified).copyWith( + isOfflineModeEnabled: isEnabled, + ), + ); } } if (state is SystemConfigurationStateSettingsFetched) { @@ -94,14 +101,16 @@ class SystemConfigurationCubit extends Cubit ); } } - showConfigurationChangedBanner(); } void updateOfflineModeTelemetryState(bool isEnabled) { if (state is SystemConfigurationStateSettingsModified) { if (!isClosed) { - emit((state as SystemConfigurationStateSettingsModified) - .copyWith(isOfflineModeTelemetryEnabled: isEnabled)); + emit( + (state as SystemConfigurationStateSettingsModified).copyWith( + isOfflineModeTelemetryEnabled: isEnabled, + ), + ); } } if (state is SystemConfigurationStateSettingsFetched) { @@ -116,14 +125,16 @@ class SystemConfigurationCubit extends Cubit ); } } - showConfigurationChangedBanner(); } void updateOfflineModeTeslaFirmwareDownloadsState(bool isEnabled) { if (state is SystemConfigurationStateSettingsModified) { if (!isClosed) { - emit((state as SystemConfigurationStateSettingsModified) - .copyWith(isOfflineModeTeslaFirmwareDownloadsEnabled: isEnabled)); + emit( + (state as SystemConfigurationStateSettingsModified).copyWith( + isOfflineModeTeslaFirmwareDownloadsEnabled: isEnabled, + ), + ); } } if (state is SystemConfigurationStateSettingsFetched) { @@ -138,26 +149,6 @@ class SystemConfigurationCubit extends Cubit ); } } - showConfigurationChangedBanner(); - } - - void showConfigurationChangedBanner() { - final context = _navigatorState.currentContext!; - ScaffoldMessenger.of(context).showMaterialBanner( - MaterialBanner( - content: const Text( - 'System configuration has been updated. Would you like to apply it during the next system startup?'), - leading: const Icon(Icons.settings), - actions: [ - IconButton( - onPressed: () { - applySystemConfiguration(); - ScaffoldMessenger.of(context).clearMaterialBanners(); - }, - icon: const Icon(Icons.save)), - ], - ), - ); } void applySystemConfiguration() async { @@ -177,12 +168,13 @@ class SystemConfigurationCubit extends Cubit await _repository.setSoftApState(isEnabledFlag ? 1 : 0); await _repository.setOfflineModeState(isOfflineModeEnabledFlag ? 1 : 0); await _repository.setOfflineModeTelemetryState( - isOfflineModeTelemetryEnabledFlag ? 1 : 0); + isOfflineModeTelemetryEnabledFlag ? 1 : 0, + ); await _repository.setOfflineModeTeslaFirmwareDownloads( - isOfflineModeTeslaFirmwareDownloadsEnabledFlag ? 1 : 0); + isOfflineModeTeslaFirmwareDownloadsEnabledFlag ? 1 : 0, + ); } catch (exception, stackTrace) { - logException( - exception: exception, stackTrace: stackTrace); + logException(exception: exception, stackTrace: stackTrace); if (!isClosed) { emit(SystemConfigurationStateSettingsSavingFailedError()); } diff --git a/lib/feature/settings/bloc/system_configuration_state.dart b/lib/feature/settings/bloc/system_configuration_state.dart index aef1bc0..20edb90 100644 --- a/lib/feature/settings/bloc/system_configuration_state.dart +++ b/lib/feature/settings/bloc/system_configuration_state.dart @@ -10,9 +10,7 @@ class SystemConfigurationStateLoading extends SystemConfigurationState {} class SystemConfigurationStateSettingsFetched extends SystemConfigurationState { final SystemConfigurationResponseBody currentConfiguration; - SystemConfigurationStateSettingsFetched({ - required this.currentConfiguration, - }); + SystemConfigurationStateSettingsFetched({required this.currentConfiguration}); } class SystemConfigurationStateSettingsFetchingError @@ -43,22 +41,25 @@ class SystemConfigurationStateSettingsModified bool? isOfflineModeEnabled, bool? isOfflineModeTelemetryEnabled, bool? isOfflineModeTeslaFirmwareDownloadsEnabled, - }) : newBandType = newBandType ?? currentConfiguration.currentSoftApBandType, - isSoftApEnabled = isSoftApEnabled ?? - (currentConfiguration.isEnabledFlag == 1 ? true : false), - isOfflineModeEnabled = isOfflineModeEnabled ?? - (currentConfiguration.isOfflineModeEnabledFlag == 1 ? true : false), - isOfflineModeTelemetryEnabled = isOfflineModeTelemetryEnabled ?? - (currentConfiguration.isOfflineModeTelemetryEnabledFlag == 1 - ? true - : false), - isOfflineModeTeslaFirmwareDownloadsEnabled = - isOfflineModeTeslaFirmwareDownloadsEnabled ?? - (currentConfiguration - .isOfflineModeTeslaFirmwareDownloadsEnabledFlag == - 1 - ? true - : false); + }) : newBandType = newBandType ?? currentConfiguration.currentSoftApBandType, + isSoftApEnabled = + isSoftApEnabled ?? + (currentConfiguration.isEnabledFlag == 1 ? true : false), + isOfflineModeEnabled = + isOfflineModeEnabled ?? + (currentConfiguration.isOfflineModeEnabledFlag == 1 ? true : false), + isOfflineModeTelemetryEnabled = + isOfflineModeTelemetryEnabled ?? + (currentConfiguration.isOfflineModeTelemetryEnabledFlag == 1 + ? true + : false), + isOfflineModeTeslaFirmwareDownloadsEnabled = + isOfflineModeTeslaFirmwareDownloadsEnabled ?? + (currentConfiguration + .isOfflineModeTeslaFirmwareDownloadsEnabledFlag == + 1 + ? true + : false); SystemConfigurationStateSettingsModified copyWith({ SystemConfigurationResponseBody? currentConfiguration, @@ -69,24 +70,23 @@ class SystemConfigurationStateSettingsModified bool? isOfflineModeTeslaFirmwareDownloadsEnabled, }) { return SystemConfigurationStateSettingsModified( - currentConfiguration: currentConfiguration ?? this.currentConfiguration, - newBandType: newBandType ?? this.newBandType, - isSoftApEnabled: isSoftApEnabled ?? this.isSoftApEnabled, - isOfflineModeEnabled: isOfflineModeEnabled ?? this.isOfflineModeEnabled, - isOfflineModeTelemetryEnabled: - isOfflineModeTelemetryEnabled ?? this.isOfflineModeTelemetryEnabled, - isOfflineModeTeslaFirmwareDownloadsEnabled: - isOfflineModeTeslaFirmwareDownloadsEnabled ?? - this.isOfflineModeTeslaFirmwareDownloadsEnabled); + currentConfiguration: currentConfiguration ?? this.currentConfiguration, + newBandType: newBandType ?? this.newBandType, + isSoftApEnabled: isSoftApEnabled ?? this.isSoftApEnabled, + isOfflineModeEnabled: isOfflineModeEnabled ?? this.isOfflineModeEnabled, + isOfflineModeTelemetryEnabled: + isOfflineModeTelemetryEnabled ?? this.isOfflineModeTelemetryEnabled, + isOfflineModeTeslaFirmwareDownloadsEnabled: + isOfflineModeTeslaFirmwareDownloadsEnabled ?? + this.isOfflineModeTeslaFirmwareDownloadsEnabled, + ); } } class SystemConfigurationStateSettingsSaved extends SystemConfigurationState { final SystemConfigurationResponseBody currentConfiguration; - SystemConfigurationStateSettingsSaved({ - required this.currentConfiguration, - }); + SystemConfigurationStateSettingsSaved({required this.currentConfiguration}); } class SystemConfigurationStateSettingsSavingFailedError diff --git a/lib/feature/settings/model/device_info.dart b/lib/feature/settings/model/device_info.dart index 52bfef6..c9c4558 100644 --- a/lib/feature/settings/model/device_info.dart +++ b/lib/feature/settings/model/device_info.dart @@ -30,7 +30,7 @@ class DeviceInfo extends Equatable { required this.isModemDetected, required this.releaseType, required this.otaUrl, - required this.isGPSEnabled + required this.isGPSEnabled, }); factory DeviceInfo.fromJson(Map json) => @@ -40,15 +40,15 @@ class DeviceInfo extends Equatable { @override List get props => [ - cpuTemperature, - serialNumber, - deviceModel, - isModemDetected, - isCarPlayDetected, - releaseType, - otaUrl, - isGPSEnabled, - ]; + cpuTemperature, + serialNumber, + deviceModel, + isModemDetected, + isCarPlayDetected, + releaseType, + otaUrl, + isGPSEnabled, + ]; } extension DeviceNameExtension on DeviceInfo { diff --git a/lib/feature/settings/model/softap_band_type.dart b/lib/feature/settings/model/softap_band_type.dart index 1af802c..5185ee6 100644 --- a/lib/feature/settings/model/softap_band_type.dart +++ b/lib/feature/settings/model/softap_band_type.dart @@ -1,22 +1,7 @@ enum SoftApBandType { - band2_4GHz( - name: "2.4 GHz", - band: 1, - channel: 6, - channelWidth: 2, - ), - band5GHz36( - name: "5 GHZ - Channel 36", - band: 2, - channel: 36, - channelWidth: 3, - ), - band5GHz44( - name: "5 GHZ - Channel 44", - band: 2, - channel: 44, - channelWidth: 3, - ), + band2_4GHz(name: "2.4 GHz", band: 1, channel: 6, channelWidth: 2), + band5GHz36(name: "5 GHZ - Channel 36", band: 2, channel: 36, channelWidth: 3), + band5GHz44(name: "5 GHZ - Channel 44", band: 2, channel: 44, channelWidth: 3), band5GHz149( name: "5 GHZ - Channel 149", band: 2, @@ -45,10 +30,11 @@ enum SoftApBandType { final int channelWidth; final String name; - static SoftApBandType matchBandTypeFromConfig( - {required band, - required channel, - required channelWidth}) { + static SoftApBandType matchBandTypeFromConfig({ + required int band, + required int channel, + required int channelWidth, + }) { if (band == 1) { return SoftApBandType.band2_4GHz; } diff --git a/lib/feature/settings/model/system_configuration_response_body.dart b/lib/feature/settings/model/system_configuration_response_body.dart index f105956..b503c7a 100644 --- a/lib/feature/settings/model/system_configuration_response_body.dart +++ b/lib/feature/settings/model/system_configuration_response_body.dart @@ -29,10 +29,7 @@ class SystemConfigurationResponseBody { defaultValue: 100, ) final int browserAudioVolume; - @JsonKey( - name: "persist.tesla-android.gps.is_active", - defaultValue: 1, - ) + @JsonKey(name: "persist.tesla-android.gps.is_active", defaultValue: 1) final int isGPSEnabled; SystemConfigurationResponseBody({ @@ -54,6 +51,38 @@ class SystemConfigurationResponseBody { Map toJson() => _$SystemConfigurationResponseBodyToJson(this); + SystemConfigurationResponseBody copyWith({ + int? bandType, + int? channel, + int? channelWidth, + int? isEnabledFlag, + int? isOfflineModeEnabledFlag, + int? isOfflineModeTelemetryEnabledFlag, + int? isOfflineModeTeslaFirmwareDownloadsEnabledFlag, + int? browserAudioIsEnabled, + int? browserAudioVolume, + int? isGPSEnabled, + }) { + return SystemConfigurationResponseBody( + bandType: bandType ?? this.bandType, + channel: channel ?? this.channel, + channelWidth: channelWidth ?? this.channelWidth, + isEnabledFlag: isEnabledFlag ?? this.isEnabledFlag, + isOfflineModeEnabledFlag: + isOfflineModeEnabledFlag ?? this.isOfflineModeEnabledFlag, + isOfflineModeTelemetryEnabledFlag: + isOfflineModeTelemetryEnabledFlag ?? + this.isOfflineModeTelemetryEnabledFlag, + isOfflineModeTeslaFirmwareDownloadsEnabledFlag: + isOfflineModeTeslaFirmwareDownloadsEnabledFlag ?? + this.isOfflineModeTeslaFirmwareDownloadsEnabledFlag, + browserAudioIsEnabled: + browserAudioIsEnabled ?? this.browserAudioIsEnabled, + browserAudioVolume: browserAudioVolume ?? this.browserAudioVolume, + isGPSEnabled: isGPSEnabled ?? this.isGPSEnabled, + ); + } + SoftApBandType get currentSoftApBandType => SoftApBandType.matchBandTypeFromConfig( band: bandType, diff --git a/lib/feature/settings/repository/device_info_repository.dart b/lib/feature/settings/repository/device_info_repository.dart index dca6786..4336f11 100644 --- a/lib/feature/settings/repository/device_info_repository.dart +++ b/lib/feature/settings/repository/device_info_repository.dart @@ -8,7 +8,7 @@ class DeviceInfoRepository { DeviceInfoRepository(this._service); - Future getDeviceInfo(){ + Future getDeviceInfo() { return _service.getDeviceInfo(); } -} \ No newline at end of file +} diff --git a/lib/feature/settings/repository/system_configuration_repository.dart b/lib/feature/settings/repository/system_configuration_repository.dart index 1bf6a43..88bd96d 100644 --- a/lib/feature/settings/repository/system_configuration_repository.dart +++ b/lib/feature/settings/repository/system_configuration_repository.dart @@ -37,8 +37,9 @@ class SystemConfigurationRepository { } Future setOfflineModeTeslaFirmwareDownloads(int isEnabledFlag) { - return _configurationService - .setOfflineModeTeslaFirmwareDownloads(isEnabledFlag); + return _configurationService.setOfflineModeTeslaFirmwareDownloads( + isEnabledFlag, + ); } Future setBrowserAudioState(int isEnabledFlag) { @@ -48,7 +49,7 @@ class SystemConfigurationRepository { Future setBrowserAudioVolume(int volume) { return _configurationService.setBrowserAudioVolume(volume); } - + Future setGPSState(int isEnabled) { return _configurationService.setGPSState(isEnabled); } diff --git a/lib/feature/settings/view_model/base_settings_view_model.dart b/lib/feature/settings/view_model/base_settings_view_model.dart new file mode 100644 index 0000000..ff8f3dc --- /dev/null +++ b/lib/feature/settings/view_model/base_settings_view_model.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +/// Base class for settings view models +/// +/// Provides common state handling patterns that reduce duplication across +/// settings widgets. Subclasses must implement the state type checking methods. +/// +/// Example usage: +/// ```dart +/// class SoundSettingsViewModel extends BaseSettingsViewModel { +/// @override +/// bool isLoadingState(AudioConfigurationState state) => +/// state is AudioConfigurationStateLoading || +/// state is AudioConfigurationStateSettingsUpdateInProgress; +/// // ... +/// } +/// ``` +abstract class BaseSettingsViewModel { + /// Check if the state represents a loading condition + bool isLoadingState(TState state); + + /// Check if settings have been successfully fetched + bool isFetchedState(TState state); + + /// Check if the state represents an error condition + bool isErrorState(TState state); + + /// Builds the appropriate widget based on the current state + /// + /// Returns the widget from [onFetched] if data is available, + /// [onLoading] widget during loading states (defaults to CircularProgressIndicator), + /// [onError] widget for error states (defaults to error text), + /// or an empty SizedBox for unknown states. + Widget buildStateWidget({ + required TState state, + required Widget Function() onFetched, + Widget Function()? onLoading, + Widget Function()? onError, + }) { + if (isFetchedState(state)) { + return onFetched(); + } else if (isLoadingState(state)) { + return onLoading?.call() ?? const CircularProgressIndicator(); + } else if (isErrorState(state)) { + return onError?.call() ?? const Text("Service error"); + } + return const SizedBox.shrink(); + } + + /// Convenience alias for state checking + bool isLoading(TState state) => isLoadingState(state); + + /// Convenience alias for state checking + bool isError(TState state) => isErrorState(state); + + /// Convenience alias for state checking + bool isFetched(TState state) => isFetchedState(state); +} diff --git a/lib/feature/settings/view_model/display_settings_view_model.dart b/lib/feature/settings/view_model/display_settings_view_model.dart new file mode 100644 index 0000000..d8fedc5 --- /dev/null +++ b/lib/feature/settings/view_model/display_settings_view_model.dart @@ -0,0 +1,68 @@ +import 'package:tesla_android/feature/display/model/remote_display_state.dart'; +import 'package:tesla_android/feature/settings/bloc/display_configuration_state.dart'; +import 'package:tesla_android/feature/settings/view_model/base_settings_view_model.dart'; + +/// View model for DisplaySettings widget +/// +/// Extracts business logic and state handling from the widget, +/// making it easier to test and maintain. +class DisplaySettingsViewModel + extends BaseSettingsViewModel { + @override + bool isLoadingState(DisplayConfigurationState state) { + return state is DisplayConfigurationStateLoading || + state is DisplayConfigurationStateSettingsUpdateInProgress; + } + + @override + bool isFetchedState(DisplayConfigurationState state) { + return state is DisplayConfigurationStateSettingsFetched; + } + + @override + bool isErrorState(DisplayConfigurationState state) { + return state is DisplayConfigurationStateError; + } + + /// Gets the current renderer value from state + DisplayRendererType? getRenderer(DisplayConfigurationState state) { + if (state is DisplayConfigurationStateSettingsFetched) { + return state.renderer; + } + return null; + } + + /// Gets the current resolution preset from state + DisplayResolutionModePreset? getResolutionPreset( + DisplayConfigurationState state, + ) { + if (state is DisplayConfigurationStateSettingsFetched) { + return state.resolutionPreset; + } + return null; + } + + /// Gets the current quality preset from state + DisplayQualityPreset? getQualityPreset(DisplayConfigurationState state) { + if (state is DisplayConfigurationStateSettingsFetched) { + return state.quality; + } + return null; + } + + /// Gets the current refresh rate from state + DisplayRefreshRatePreset? getRefreshRate(DisplayConfigurationState state) { + if (state is DisplayConfigurationStateSettingsFetched) { + return state.refreshRate; + } + return null; + } + + /// Gets the responsiveness setting from state + bool? getResponsiveness(DisplayConfigurationState state) { + if (state is DisplayConfigurationStateSettingsFetched) { + return state.isResponsive; + } + return null; + } +} diff --git a/lib/feature/settings/view_model/hotspot_settings_view_model.dart b/lib/feature/settings/view_model/hotspot_settings_view_model.dart new file mode 100644 index 0000000..5e249dc --- /dev/null +++ b/lib/feature/settings/view_model/hotspot_settings_view_model.dart @@ -0,0 +1,83 @@ +import 'package:tesla_android/feature/settings/bloc/system_configuration_state.dart'; +import 'package:tesla_android/feature/settings/model/softap_band_type.dart'; +import 'package:tesla_android/feature/settings/view_model/base_settings_view_model.dart'; + +/// View model for HotspotSettings widget +/// +/// Extracts business logic and state handling from the widget. +class HotspotSettingsViewModel + extends BaseSettingsViewModel { + @override + bool isLoadingState(SystemConfigurationState state) { + return !isFetchedState(state) && !isErrorState(state); + } + + @override + bool isFetchedState(SystemConfigurationState state) { + return state is SystemConfigurationStateSettingsFetched || + state is SystemConfigurationStateSettingsModified; + } + + @override + bool isErrorState(SystemConfigurationState state) { + return state is SystemConfigurationStateSettingsFetchingError || + state is SystemConfigurationStateSettingsSavingFailedError; + } + + /// Gets the current soft AP band type from state + SoftApBandType? getSoftApBand(SystemConfigurationState state) { + if (state is SystemConfigurationStateSettingsFetched) { + return state.currentConfiguration.currentSoftApBandType; + } else if (state is SystemConfigurationStateSettingsModified) { + return state.newBandType; + } + return null; + } + + /// Gets offline mode enabled status from state + bool? getOfflineModeEnabled(SystemConfigurationState state) { + if (state is SystemConfigurationStateSettingsFetched) { + return state.currentConfiguration.isOfflineModeEnabledFlag == 1; + } else if (state is SystemConfigurationStateSettingsModified) { + return state.isOfflineModeEnabled; + } + return null; + } + + /// Gets offline mode telemetry enabled status from state + bool? getOfflineModeTelemetryEnabled(SystemConfigurationState state) { + if (state is SystemConfigurationStateSettingsFetched) { + return state.currentConfiguration.isOfflineModeTelemetryEnabledFlag == 1; + } else if (state is SystemConfigurationStateSettingsModified) { + return state.isOfflineModeTelemetryEnabled; + } + return null; + } + + /// Gets Tesla firmware downloads enabled status from state + bool? getTeslaFirmwareDownloadsEnabled(SystemConfigurationState state) { + if (state is SystemConfigurationStateSettingsFetched) { + return state + .currentConfiguration + .isOfflineModeTeslaFirmwareDownloadsEnabledFlag == + 1; + } else if (state is SystemConfigurationStateSettingsModified) { + return state.isOfflineModeTeslaFirmwareDownloadsEnabled; + } + return null; + } + + /// Checks if the state represents fetchable settings + /// @deprecated Use isFetched() or isFetchedState() instead + bool hasSettings(SystemConfigurationState state) => isFetchedState(state); + + /// Checks if there's a fetching error + bool isFetchError(SystemConfigurationState state) { + return state is SystemConfigurationStateSettingsFetchingError; + } + + /// Checks if there's a saving error + bool isSaveError(SystemConfigurationState state) { + return state is SystemConfigurationStateSettingsSavingFailedError; + } +} diff --git a/lib/feature/settings/view_model/rear_display_settings_view_model.dart b/lib/feature/settings/view_model/rear_display_settings_view_model.dart new file mode 100644 index 0000000..bfa6ce9 --- /dev/null +++ b/lib/feature/settings/view_model/rear_display_settings_view_model.dart @@ -0,0 +1,48 @@ +import 'package:tesla_android/feature/settings/bloc/rear_display_configuration_state.dart'; +import 'package:tesla_android/feature/settings/view_model/base_settings_view_model.dart'; + +/// View model for RearDisplaySettings widget +/// +/// Extracts business logic and state handling from the widget. +class RearDisplaySettingsViewModel + extends BaseSettingsViewModel { + @override + bool isLoadingState(RearDisplayConfigurationState state) { + return state is RearDisplayConfigurationStateLoading || + state is RearDisplayConfigurationStateSettingsUpdateInProgress; + } + + @override + bool isFetchedState(RearDisplayConfigurationState state) { + return state is RearDisplayConfigurationStateSettingsFetched; + } + + @override + bool isErrorState(RearDisplayConfigurationState state) { + return state is RearDisplayConfigurationStateError; + } + + /// Gets rear display enabled status from state + bool? getRearDisplayEnabled(RearDisplayConfigurationState state) { + if (state is RearDisplayConfigurationStateSettingsFetched) { + return state.isRearDisplayEnabled; + } + return null; + } + + /// Gets rear display priority status from state + bool? getRearDisplayPrioritised(RearDisplayConfigurationState state) { + if (state is RearDisplayConfigurationStateSettingsFetched) { + return state.isRearDisplayPrioritised; + } + return null; + } + + /// Gets current display primary status from state + bool? getCurrentDisplayPrimary(RearDisplayConfigurationState state) { + if (state is RearDisplayConfigurationStateSettingsFetched) { + return state.isCurrentDisplayPrimary; + } + return null; + } +} diff --git a/lib/feature/settings/view_model/sound_settings_view_model.dart b/lib/feature/settings/view_model/sound_settings_view_model.dart new file mode 100644 index 0000000..e585087 --- /dev/null +++ b/lib/feature/settings/view_model/sound_settings_view_model.dart @@ -0,0 +1,40 @@ +import 'package:tesla_android/feature/settings/bloc/audio_configuration_state.dart'; +import 'package:tesla_android/feature/settings/view_model/base_settings_view_model.dart'; + +/// View model for SoundSettings widget +/// +/// Extracts business logic and state handling from the widget. +class SoundSettingsViewModel + extends BaseSettingsViewModel { + @override + bool isLoadingState(AudioConfigurationState state) { + return state is AudioConfigurationStateLoading || + state is AudioConfigurationStateSettingsUpdateInProgress; + } + + @override + bool isFetchedState(AudioConfigurationState state) { + return state is AudioConfigurationStateSettingsFetched; + } + + @override + bool isErrorState(AudioConfigurationState state) { + return state is AudioConfigurationStateError; + } + + /// Gets audio enabled status from state + bool? getAudioEnabled(AudioConfigurationState state) { + if (state is AudioConfigurationStateSettingsFetched) { + return state.isEnabled; + } + return null; + } + + /// Gets volume from state + int? getVolume(AudioConfigurationState state) { + if (state is AudioConfigurationStateSettingsFetched) { + return state.volume; + } + return null; + } +} diff --git a/lib/feature/settings/widget/device_settings.dart b/lib/feature/settings/widget/device_settings.dart index 4f4f91b..288e229 100644 --- a/lib/feature/settings/widget/device_settings.dart +++ b/lib/feature/settings/widget/device_settings.dart @@ -9,90 +9,93 @@ import 'package:tesla_android/feature/settings/widget/settings_tile.dart'; class DeviceSettings extends SettingsSection { const DeviceSettings({super.key}) - : super( - name: "Device", - icon: Icons.developer_board, - ); + : super(name: "Device", icon: Icons.developer_board); @override Widget body(BuildContext context) { final textTheme = Theme.of(context).textTheme.bodyLarge; return BlocBuilder( - builder: (context, state) { - if (state is DeviceInfoStateInitial || state is DeviceInfoStateLoading) { - return const Center( - child: CircularProgressIndicator(), - ); - } else if (state is DeviceInfoStateLoaded) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SettingsTile( + builder: (context, state) { + if (state is DeviceInfoStateInitial || + state is DeviceInfoStateLoading) { + return const Center(child: CircularProgressIndicator()); + } else if (state is DeviceInfoStateLoaded) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingsTile( icon: Icons.device_thermostat, title: 'CPU Temperature', - trailing: Text("${state.deviceInfo.cpuTemperature}°C", - style: textTheme)), - const Padding( - padding: EdgeInsets.all(TADimens.PADDING_S_VALUE), - child: Text( - 'CPU temperature should not exceed 80°C. Make sure the device is actively cooled and proper ventilation is provided.'), - ), - divider, - SettingsTile( + trailing: Text( + "${state.deviceInfo.cpuTemperature}°C", + style: textTheme, + ), + ), + const Padding( + padding: EdgeInsets.all(TADimens.PADDING_S_VALUE), + child: Text( + 'CPU temperature should not exceed 80°C. Make sure the device is actively cooled and proper ventilation is provided.', + ), + ), + divider, + SettingsTile( dense: false, icon: Icons.perm_device_info, title: 'Model', - trailing: Text( - state.deviceInfo.deviceName, - style: textTheme, - )), - SettingsTile( + trailing: Text(state.deviceInfo.deviceName, style: textTheme), + ), + SettingsTile( dense: false, icon: Icons.developer_board_rounded, title: 'Serial Number', - trailing: Text( - state.deviceInfo.serialNumber, - style: textTheme, - )), - SettingsTile( + trailing: Text(state.deviceInfo.serialNumber, style: textTheme), + ), + SettingsTile( dense: false, icon: Icons.broadcast_on_home_sharp, title: 'CarPlay Module', trailing: Text( - state.deviceInfo.isCarPlayDetected == 1 ? "Connected" : "Not connected", + state.deviceInfo.isCarPlayDetected == 1 + ? "Connected" + : "Not connected", style: textTheme, - )), - SettingsTile( + ), + ), + SettingsTile( dense: false, icon: Icons.cell_tower, title: 'LTE Modem', trailing: Text( - state.deviceInfo.isModemDetected == 1 ? "Detected" : "Not detected", + state.deviceInfo.isModemDetected == 1 + ? "Detected" + : "Not detected", style: textTheme, - )), - const Padding( - padding: EdgeInsets.all(TADimens.PADDING_S_VALUE), - child: Text( - 'The LTE modem is considered detected when it is properly connected, and the gateway is reachable by Android. IP address 192.168.(0/8).1 is used for this check (Default for E3372 and Alcatel modems).'), - ), - SettingsTile( + ), + ), + const Padding( + padding: EdgeInsets.all(TADimens.PADDING_S_VALUE), + child: Text( + 'The LTE modem is considered detected when it is properly connected, and the gateway is reachable by Android. IP address 192.168.(0/8).1 is used for this check (Default for E3372 and Alcatel modems).', + ), + ), + SettingsTile( dense: false, icon: Icons.update, title: 'Release type', - trailing: Text( - state.deviceInfo.releaseType, - style: textTheme, - )), - const Padding( - padding: EdgeInsets.all(TADimens.PADDING_S_VALUE), - child: Text( - 'No support is provided for devices that are running pre-release (beta) software. You can switch your desired release type on https://beta.teslaandroid.com'), - ), - ], - ); - } else { - return const SizedBox(); - } - }); + trailing: Text(state.deviceInfo.releaseType, style: textTheme), + ), + const Padding( + padding: EdgeInsets.all(TADimens.PADDING_S_VALUE), + child: Text( + 'No support is provided for devices that are running pre-release (beta) software. You can switch your desired release type on https://beta.teslaandroid.com', + ), + ), + ], + ); + } else { + return const SizedBox(); + } + }, + ); } } diff --git a/lib/feature/settings/widget/display_settings.dart b/lib/feature/settings/widget/display_settings.dart index efdf525..075b0e8 100644 --- a/lib/feature/settings/widget/display_settings.dart +++ b/lib/feature/settings/widget/display_settings.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:tesla_android/common/di/ta_locator.dart'; +import 'package:tesla_android/common/ui/components/settings_dropdown.dart'; import 'package:tesla_android/common/ui/constants/ta_dimens.dart'; import 'package:tesla_android/feature/display/model/remote_display_state.dart'; import 'package:tesla_android/feature/settings/bloc/display_configuration_cubit.dart'; import 'package:tesla_android/feature/settings/bloc/display_configuration_state.dart'; +import 'package:tesla_android/feature/settings/view_model/display_settings_view_model.dart'; import 'package:tesla_android/feature/settings/widget/settings_section.dart'; import 'package:tesla_android/feature/settings/widget/settings_tile.dart'; @@ -15,6 +16,7 @@ class DisplaySettings extends SettingsSection { @override Widget body(BuildContext context) { final cubit = BlocProvider.of(context); + final viewModel = DisplaySettingsViewModel(); return BlocBuilder( builder: (context, state) { @@ -25,7 +27,18 @@ class DisplaySettings extends SettingsSection { icon: Icons.texture, title: 'Renderer', dense: false, - trailing: _rendererDropdown(context, cubit, state), + trailing: _buildDropdown( + context: context, + cubit: cubit, + state: state, + viewModel: viewModel, + getValue: viewModel.getRenderer, + items: DisplayRendererType.values, + onChanged: (value) { + cubit.setRenderer(value!); + _showConfigurationChangedBanner(context); + }, + ), ), const Padding( padding: EdgeInsets.all(TADimens.PADDING_S_VALUE), @@ -37,7 +50,18 @@ class DisplaySettings extends SettingsSection { SettingsTile( icon: Icons.display_settings, title: 'Resolution', - trailing: _resolutionDropdown(context, cubit, state), + trailing: _buildDropdown( + context: context, + cubit: cubit, + state: state, + viewModel: viewModel, + getValue: viewModel.getResolutionPreset, + items: DisplayResolutionModePreset.values, + onChanged: (value) { + cubit.setResolution(value!); + _showConfigurationChangedBanner(context); + }, + ), ), const Padding( padding: EdgeInsets.all(TADimens.PADDING_S_VALUE), @@ -49,7 +73,18 @@ class DisplaySettings extends SettingsSection { SettingsTile( icon: Icons.photo_size_select_actual_outlined, title: 'Image quality', - trailing: _qualityDropdown(context, cubit, state), + trailing: _buildDropdown( + context: context, + cubit: cubit, + state: state, + viewModel: viewModel, + getValue: viewModel.getQualityPreset, + items: DisplayQualityPreset.values, + onChanged: (value) { + cubit.setQuality(value!); + _showConfigurationChangedBanner(context); + }, + ), ), const Padding( padding: EdgeInsets.all(TADimens.PADDING_S_VALUE), @@ -61,7 +96,18 @@ class DisplaySettings extends SettingsSection { SettingsTile( icon: Icons.monitor, title: 'Refresh rate', - trailing: _refreshRateDropdown(context, cubit, state), + trailing: _buildDropdown( + context: context, + cubit: cubit, + state: state, + viewModel: viewModel, + getValue: viewModel.getRefreshRate, + items: DisplayRefreshRatePreset.values, + onChanged: (value) { + cubit.setRefreshRate(value!); + _showConfigurationChangedBanner(context); + }, + ), ), const Padding( padding: EdgeInsets.all(TADimens.PADDING_S_VALUE), @@ -73,7 +119,7 @@ class DisplaySettings extends SettingsSection { SettingsTile( icon: Icons.photo_size_select_large, title: 'Dynamic aspect ratio', - trailing: _responsivenessSwitch(context, cubit, state), + trailing: _responsivenessSwitch(context, cubit, state, viewModel), ), const Padding( padding: EdgeInsets.all(TADimens.PADDING_S_VALUE), @@ -87,162 +133,54 @@ class DisplaySettings extends SettingsSection { ); } - Widget _responsivenessSwitch( - BuildContext context, - DisplayConfigurationCubit cubit, - DisplayConfigurationState state, - ) { - if (state is DisplayConfigurationStateSettingsFetched) { - return Switch( - value: state.isResponsive, - onChanged: (bool value) { - cubit.setResponsiveness(value); - _showConfigurationChangedBanner(context); - }, - ); - } else if (state is DisplayConfigurationStateSettingsUpdateInProgress || - state is DisplayConfigurationStateLoading) { - return const CircularProgressIndicator(); - } else if (state is DisplayConfigurationStateError) { - return const Text("Service error"); - } - return const SizedBox.shrink(); - } - - Widget _resolutionDropdown( - BuildContext context, - DisplayConfigurationCubit cubit, - DisplayConfigurationState state, - ) { - if (state is DisplayConfigurationStateSettingsFetched) { - return DropdownButton( - value: state.resolutionPreset, - icon: const Icon(Icons.arrow_drop_down_outlined), - underline: Container(height: 2, color: Theme.of(context).primaryColor), - onChanged: (DisplayResolutionModePreset? value) { - if (value != null) { - cubit.setResolution(value); - _showConfigurationChangedBanner(context); - } - }, - items: DisplayResolutionModePreset.values - .map>(( - DisplayResolutionModePreset value, - ) { - return DropdownMenuItem( - value: value, - child: Text(value.name()), - ); - }) - .toList(), - ); - } else if (state is DisplayConfigurationStateSettingsUpdateInProgress || - state is DisplayConfigurationStateLoading) { - return const CircularProgressIndicator(); - } else if (state is DisplayConfigurationStateError) { - return const Text("Service error"); - } - return const SizedBox.shrink(); - } + /// Generic dropdown builder using view model and reusable component + Widget _buildDropdown({ + required BuildContext context, + required DisplayConfigurationCubit cubit, + required DisplayConfigurationState state, + required DisplaySettingsViewModel viewModel, + required T? Function(DisplayConfigurationState) getValue, + required List items, + required ValueChanged onChanged, + }) { + return viewModel.buildStateWidget( + state: state, + onFetched: () { + final value = getValue(state); + if (value == null) return const SizedBox.shrink(); - Widget _qualityDropdown( - BuildContext context, - DisplayConfigurationCubit cubit, - DisplayConfigurationState state, - ) { - if (state is DisplayConfigurationStateSettingsFetched) { - return DropdownButton( - value: state.quality, - icon: const Icon(Icons.arrow_drop_down_outlined), - underline: Container(height: 2, color: Theme.of(context).primaryColor), - onChanged: (DisplayQualityPreset? value) { - if (value != null) { - cubit.setQuality(value); - _showConfigurationChangedBanner(context); - } - }, - items: DisplayQualityPreset.values.map((value) { - return DropdownMenuItem( - value: value, - child: Text(value.name()), - ); - }).toList(), - ); - } else if (state is DisplayConfigurationStateSettingsUpdateInProgress || - state is DisplayConfigurationStateLoading) { - return const CircularProgressIndicator(); - } else if (state is DisplayConfigurationStateError) { - return const Text("Service error"); - } - return const SizedBox.shrink(); + return SettingsDropdown( + value: value, + items: items, + onChanged: onChanged, + itemLabel: (item) => (item as dynamic).name(), + underlineColor: Theme.of(context).primaryColor, + ); + }, + ); } - Widget _refreshRateDropdown( + Widget _responsivenessSwitch( BuildContext context, DisplayConfigurationCubit cubit, DisplayConfigurationState state, + DisplaySettingsViewModel viewModel, ) { - if (state is DisplayConfigurationStateSettingsFetched) { - return DropdownButton( - value: state.refreshRate, - icon: const Icon(Icons.arrow_drop_down_outlined), - underline: Container(height: 2, color: Theme.of(context).primaryColor), - onChanged: (DisplayRefreshRatePreset? value) { - if (value != null) { - cubit.setRefreshRate(value); - _showConfigurationChangedBanner(context); - } - }, - items: DisplayRefreshRatePreset.values.map((value) { - return DropdownMenuItem( - value: value, - child: Text(value.name()), - ); - }).toList(), - ); - } else if (state is DisplayConfigurationStateSettingsUpdateInProgress || - state is DisplayConfigurationStateLoading) { - return const CircularProgressIndicator(); - } else if (state is DisplayConfigurationStateError) { - return const Text("Service error"); - } - return const SizedBox.shrink(); - } + return viewModel.buildStateWidget( + state: state, + onFetched: () { + final isResponsive = viewModel.getResponsiveness(state); + if (isResponsive == null) return const SizedBox.shrink(); - Widget _rendererDropdown( - BuildContext context, - DisplayConfigurationCubit cubit, - DisplayConfigurationState state, - ) { - if (state is DisplayConfigurationStateSettingsFetched) { - return DropdownButton( - value: state.renderer, - icon: const Icon(Icons.arrow_drop_down_outlined), - underline: Container(height: 2, color: Theme.of(context).primaryColor), - onChanged: (DisplayRendererType? value) { - if (value != null) { - cubit.setRenderer(value); + return Switch( + value: isResponsive, + onChanged: (bool value) { + cubit.setResponsiveness(value); _showConfigurationChangedBanner(context); - } - }, - items: DisplayRendererType.values - .map>(( - DisplayRendererType value, - ) { - return DropdownMenuItem( - value: value, - child: Text(value.name()), - ); - }) - .toList(), - ); - } else if (state is DisplayConfigurationStateSettingsUpdateInProgress || - state is DisplayConfigurationStateLoading) { - return const CircularProgressIndicator(); - } else if (state is DisplayConfigurationStateError) { - return const Text("Service error"); - } - return const SizedBox.shrink(); + }, + ); + }, + ); } void _showConfigurationChangedBanner(BuildContext context) { diff --git a/lib/feature/settings/widget/gps_settings.dart b/lib/feature/settings/widget/gps_settings.dart index 5558720..5f26b40 100644 --- a/lib/feature/settings/widget/gps_settings.dart +++ b/lib/feature/settings/widget/gps_settings.dart @@ -7,44 +7,42 @@ import 'package:tesla_android/feature/settings/widget/settings_section.dart'; import 'package:tesla_android/feature/settings/widget/settings_tile.dart'; class GpsSettings extends SettingsSection { - const GpsSettings({super.key}) - : super( - name: "GPS", - icon: Icons.gps_fixed, - ); + const GpsSettings({super.key}) : super(name: "GPS", icon: Icons.gps_fixed); @override Widget body(BuildContext context) { return BlocBuilder( builder: (context, state) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SettingsTile( - icon: Icons.gps_fixed, - title: 'GPS', - subtitle: 'Disable if you don\'t use Android navigation apps', - trailing: _gpsStateSwitch(context, state)), - const Padding( - padding: EdgeInsets.all(TADimens.PADDING_S_VALUE), - child: Text( - 'NOTE: GPS via Browser can cause crashes on Tesla Software 2024.14 or newer, the integration is disabled by default until this issue is solved by Tesla. Please be assured that your location data never leaves your car. The real-time location updates sent to your Tesla Android device are solely utilized to emulate a hardware GPS module in the Android OS.'), - ), - ], - ); - } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingsTile( + icon: Icons.gps_fixed, + title: 'GPS', + subtitle: 'Disable if you don\'t use Android navigation apps', + trailing: _gpsStateSwitch(context, state), + ), + const Padding( + padding: EdgeInsets.all(TADimens.PADDING_S_VALUE), + child: Text( + 'NOTE: GPS via Browser can cause crashes on Tesla Software 2024.14 or newer, the integration is disabled by default until this issue is solved by Tesla. Please be assured that your location data never leaves your car. The real-time location updates sent to your Tesla Android device are solely utilized to emulate a hardware GPS module in the Android OS.', + ), + ), + ], + ); + }, ); } - Widget _gpsStateSwitch(BuildContext context, - GPSConfigurationState state) { + Widget _gpsStateSwitch(BuildContext context, GPSConfigurationState state) { final cubit = BlocProvider.of(context); if (state is GPSConfigurationStateLoaded) { return Switch( - value: state.isGPSEnabled, - onChanged: (value) { - cubit.setState(value); - }); + value: state.isGPSEnabled, + onChanged: (value) { + cubit.setState(value); + }, + ); } else if (state is GPSConfigurationStateUpdateInProgress || state is GPSConfigurationStateLoading) { return const CircularProgressIndicator(); @@ -53,4 +51,4 @@ class GpsSettings extends SettingsSection { } return const SizedBox.shrink(); } -} \ No newline at end of file +} diff --git a/lib/feature/settings/widget/hotspot_settings.dart b/lib/feature/settings/widget/hotspot_settings.dart index 0594e47..395eee6 100644 --- a/lib/feature/settings/widget/hotspot_settings.dart +++ b/lib/feature/settings/widget/hotspot_settings.dart @@ -1,69 +1,85 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tesla_android/common/di/ta_locator.dart'; +import 'package:tesla_android/common/service/dialog_service.dart'; +import 'package:tesla_android/common/ui/components/settings_dropdown.dart'; +import 'package:tesla_android/common/ui/components/settings_switch.dart'; import 'package:tesla_android/common/ui/constants/ta_dimens.dart'; import 'package:tesla_android/feature/settings/bloc/system_configuration_cubit.dart'; import 'package:tesla_android/feature/settings/bloc/system_configuration_state.dart'; import 'package:tesla_android/feature/settings/model/softap_band_type.dart'; +import 'package:tesla_android/feature/settings/view_model/hotspot_settings_view_model.dart'; import 'package:tesla_android/feature/settings/widget/settings_section.dart'; import 'package:tesla_android/feature/settings/widget/settings_tile.dart'; class HotspotSettings extends SettingsSection { - const HotspotSettings({super.key}) - : super( - name: "Network", - icon: Icons.wifi, - ); + const HotspotSettings({super.key}) : super(name: "Network", icon: Icons.wifi); @override Widget body(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - if (state is SystemConfigurationStateSettingsFetched || - state is SystemConfigurationStateSettingsModified) { - return _content(context, state); - } else if (state is SystemConfigurationStateSettingsFetchingError) { - return const Text( - "Failed to fetch Wi-Fi configuration from your device."); - } else if (state is SystemConfigurationStateSettingsSavingFailedError) { - return const Text("Failed to save your new Wi-Fi configuration"); - } else { - return const Padding( - padding: EdgeInsets.all(TADimens.PADDING_L_VALUE), - child: Center(child: CircularProgressIndicator()), + final viewModel = HotspotSettingsViewModel(); + + return BlocListener( + listener: (context, state) { + if (state is SystemConfigurationStateSettingsModified) { + getIt().showMaterialBanner( + context: context, + banner: MaterialBanner( + content: const Text( + 'System configuration has been updated. Would you like to apply it during the next system startup?', + ), + leading: const Icon(Icons.settings), + actions: [ + IconButton( + onPressed: () { + context + .read() + .applySystemConfiguration(); + getIt().clearMaterialBanners( + context: context, + ); + }, + icon: const Icon(Icons.save), + ), + ], + ), ); } }, + child: BlocBuilder( + builder: (context, state) { + if (viewModel.hasSettings(state)) { + return _content(context, state, viewModel); + } else if (viewModel.isFetchError(state)) { + return const Text( + "Failed to fetch Wi-Fi configuration from your device.", + ); + } else if (viewModel.isSaveError(state)) { + return const Text("Failed to save your new Wi-Fi configuration"); + } else { + return const Padding( + padding: EdgeInsets.all(TADimens.PADDING_L_VALUE), + child: Center(child: CircularProgressIndicator()), + ); + } + }, + ), ); } - Widget _content(BuildContext context, SystemConfigurationState state) { + Widget _content( + BuildContext context, + SystemConfigurationState state, + HotspotSettingsViewModel viewModel, + ) { final cubit = BlocProvider.of(context); - final selectedBand = (state is SystemConfigurationStateSettingsFetched) - ? state.currentConfiguration.currentSoftApBandType - : (state as SystemConfigurationStateSettingsModified).newBandType; - final isOfflineModeEnabled = - (state is SystemConfigurationStateSettingsFetched) - ? (state.currentConfiguration.isOfflineModeEnabledFlag == 1 - ? true - : false) - : (state as SystemConfigurationStateSettingsModified) - .isOfflineModeEnabled; - final isOfflineModeTelemetryEnabled = - (state is SystemConfigurationStateSettingsFetched) - ? (state.currentConfiguration.isOfflineModeTelemetryEnabledFlag == 1 - ? true - : false) - : (state as SystemConfigurationStateSettingsModified) - .isOfflineModeTelemetryEnabled; - final isOfflineModeTeslaFirmwareDownloadsEnabled = - (state is SystemConfigurationStateSettingsFetched) - ? (state.currentConfiguration - .isOfflineModeTeslaFirmwareDownloadsEnabledFlag == - 1 - ? true - : false) - : (state as SystemConfigurationStateSettingsModified) - .isOfflineModeTeslaFirmwareDownloadsEnabled; + final selectedBand = viewModel.getSoftApBand(state); + final isOfflineModeEnabled = viewModel.getOfflineModeEnabled(state); + final isOfflineModeTelemetryEnabled = viewModel + .getOfflineModeTelemetryEnabled(state); + final isOfflineModeTeslaFirmwareDownloadsEnabled = viewModel + .getTeslaFirmwareDownloadsEnabled(state); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -78,77 +94,78 @@ class HotspotSettings extends SettingsSection { // cubit.updateSoftApState(value); // })), // divider, - SettingsTile( - icon: Icons.wifi_channel, - dense: false, - title: 'Frequency band and channel', - trailing: DropdownButton( - value: selectedBand, - icon: const Icon(Icons.arrow_drop_down_outlined), - underline: Container( - height: 2, - color: Theme.of(context).primaryColor, - ), - onChanged: (SoftApBandType? value) { - if (value != null) { - cubit.updateSoftApBand(value); - } - }, - items: SoftApBandType.values - .map>( - (SoftApBandType value) { - return DropdownMenuItem( - value: value, - child: Text(value.name), - ); - }).toList(), - )), + icon: Icons.wifi_channel, + dense: false, + title: 'Frequency band and channel', + trailing: selectedBand != null + ? SettingsDropdown( + value: selectedBand, + items: SoftApBandType.values, + onChanged: (value) { + if (value != null) { + cubit.updateSoftApBand(value); + } + }, + itemLabel: (item) => item.name, + underlineColor: Theme.of(context).primaryColor, + ) + : const SizedBox.shrink(), + ), const Padding( padding: EdgeInsets.all(TADimens.PADDING_S_VALUE), child: Text( - 'The utilization of the 5 GHz operation mode enhances the performance of the Tesla Android system while effectively resolving Bluetooth-related challenges. If your car does not see the Tesla Android network please change the channel, supported channels differ by region. This mode is anticipated to be designated as the default option in a future versions.\n\nConversely, when operating on the 2.4 GHz frequency, the allocation of resources between the hotspot and Bluetooth can lead to dropped frames, particularly when utilizing AD2P audio.'), + 'The utilization of the 5 GHz operation mode enhances the performance of the Tesla Android system while effectively resolving Bluetooth-related challenges. If your car does not see the Tesla Android network please change the channel, supported channels differ by region. This mode is anticipated to be designated as the default option in a future versions.\n\nConversely, when operating on the 2.4 GHz frequency, the allocation of resources between the hotspot and Bluetooth can lead to dropped frames, particularly when utilizing AD2P audio.', + ), ), divider, SettingsTile( - icon: Icons.wifi_off, - title: 'Offline mode', - subtitle: 'Persistent Wi-Fi connection', - trailing: Switch( - value: isOfflineModeEnabled, - onChanged: (value) { - cubit.updateOfflineModeState(value); - })), + icon: Icons.wifi_off, + title: 'Offline mode', + subtitle: 'Persistent Wi-Fi connection', + trailing: SettingsSwitch( + value: isOfflineModeEnabled ?? false, + onChanged: (value) { + cubit.updateOfflineModeState(value); + }, + ), + ), divider, const Padding( padding: EdgeInsets.all(TADimens.PADDING_S_VALUE), child: Text( - 'To ensure continuous internet access, your Tesla vehicle relies on Wi-Fi networks that have an active internet connection. However, if you encounter a situation where Wi-Fi connectivity is unavailable, there is a solution called "offline mode" to address this limitation. In offline mode, certain features like Tesla Mobile App access and other car-side functionalities that rely on internet connectivity will be disabled. To overcome this limitation, you can establish internet access in your Tesla Android setup by using an LTE Modem or enabling tethering.'), + 'To ensure continuous internet access, your Tesla vehicle relies on Wi-Fi networks that have an active internet connection. However, if you encounter a situation where Wi-Fi connectivity is unavailable, there is a solution called "offline mode" to address this limitation. In offline mode, certain features like Tesla Mobile App access and other car-side functionalities that rely on internet connectivity will be disabled. To overcome this limitation, you can establish internet access in your Tesla Android setup by using an LTE Modem or enabling tethering.', + ), ), divider, SettingsTile( - icon: Icons.data_thresholding_sharp, - title: 'Tesla Telemetry', - subtitle: 'Reduces data usage, uncheck to disable', - trailing: Switch( - value: isOfflineModeTelemetryEnabled, - onChanged: (value) { - cubit.updateOfflineModeTelemetryState(value); - })), + icon: Icons.data_thresholding_sharp, + title: 'Tesla Telemetry', + subtitle: 'Reduces data usage, uncheck to disable', + trailing: SettingsSwitch( + value: isOfflineModeTelemetryEnabled ?? false, + onChanged: (value) { + cubit.updateOfflineModeTelemetryState(value); + }, + ), + ), divider, SettingsTile( - icon: Icons.update, - title: 'Tesla Software Updates', - subtitle: 'Reduces data usage, uncheck to disable', - trailing: Switch( - value: isOfflineModeTeslaFirmwareDownloadsEnabled, - onChanged: (value) { - cubit.updateOfflineModeTeslaFirmwareDownloadsState(value); - })), + icon: Icons.update, + title: 'Tesla Software Updates', + subtitle: 'Reduces data usage, uncheck to disable', + trailing: SettingsSwitch( + value: isOfflineModeTeslaFirmwareDownloadsEnabled ?? false, + onChanged: (value) { + cubit.updateOfflineModeTeslaFirmwareDownloadsState(value); + }, + ), + ), const Padding( padding: EdgeInsets.all(TADimens.PADDING_S_VALUE), child: Text( - 'Your car will still be able to check the availability of new updates. With this option enabled they won\'t immediately start downloading'), + 'Your car will still be able to check the availability of new updates. With this option enabled they won\'t immediately start downloading', + ), ), ], ); diff --git a/lib/feature/settings/widget/rear_display_settings.dart b/lib/feature/settings/widget/rear_display_settings.dart index 911b3db..27d1097 100644 --- a/lib/feature/settings/widget/rear_display_settings.dart +++ b/lib/feature/settings/widget/rear_display_settings.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tesla_android/common/ui/components/settings_switch.dart'; import 'package:tesla_android/common/ui/constants/ta_dimens.dart'; import 'package:tesla_android/feature/settings/bloc/rear_display_configuration_cubit.dart'; import 'package:tesla_android/feature/settings/bloc/rear_display_configuration_state.dart'; +import 'package:tesla_android/feature/settings/view_model/rear_display_settings_view_model.dart'; import 'package:tesla_android/feature/settings/widget/settings_section.dart'; import 'package:tesla_android/feature/settings/widget/settings_tile.dart'; @@ -13,6 +15,7 @@ class RearDisplaySettings extends SettingsSection { @override Widget body(BuildContext context) { final cubit = BlocProvider.of(context); + final viewModel = RearDisplaySettingsViewModel(); return BlocBuilder< RearDisplayConfigurationCubit, @@ -25,16 +28,29 @@ class RearDisplaySettings extends SettingsSection { SettingsTile( icon: Icons.monitor, title: 'Rear Display Support', - trailing: _rearDisplayStateSwitch(context, cubit, state), + trailing: viewModel.buildStateWidget( + state: state, + onFetched: () { + final isEnabled = viewModel.getRearDisplayEnabled(state); + if (isEnabled == null) return const SizedBox.shrink(); + + return SettingsSwitch( + value: isEnabled, + onChanged: (value) { + cubit.setRearDisplayState(value); + }, + ); + }, + ), ), const Padding( padding: EdgeInsets.all(TADimens.PADDING_S_VALUE), child: Text( - 'Enable if your vehicle is equipped with a factory rear display.\n\n' - 'Supported models:\n\n' - '- Model 3 (2023+ / “Highland”)\n' - '- Model Y (2025+ / “Juniper”)\n' - '- Model S/X (2021+)\n' + 'Enable if your vehicle is equipped with a factory rear display.\\n\\n' + 'Supported models:\\n\\n' + '- Model 3 (2023+ / "Highland")\\n' + '- Model Y (2025+ / "Juniper")\\n' + '- Model S/X (2021+)\\n' '- Cybertruck', ), ), @@ -44,7 +60,20 @@ class RearDisplaySettings extends SettingsSection { SettingsTile( icon: Icons.screenshot_monitor, title: 'Primary Display', - trailing: _isPrimaryDisplaySwitch(context, cubit, state), + trailing: viewModel.buildStateWidget( + state: state, + onFetched: () { + final isPrimary = viewModel.getCurrentDisplayPrimary(state); + if (isPrimary == null) return const SizedBox.shrink(); + + return SettingsSwitch( + value: isPrimary, + onChanged: (value) { + cubit.setDisplayType(isCurrentDisplayPrimary: value); + }, + ); + }, + ), ), const Padding( padding: EdgeInsets.all(TADimens.PADDING_S_VALUE), @@ -56,7 +85,22 @@ class RearDisplaySettings extends SettingsSection { SettingsTile( icon: Icons.aspect_ratio, title: 'Rear Display Priority', - trailing: _rearDisplayPrioritySwitch(context, cubit, state), + trailing: viewModel.buildStateWidget( + state: state, + onFetched: () { + final isPrioritised = viewModel.getRearDisplayPrioritised( + state, + ); + if (isPrioritised == null) return const SizedBox.shrink(); + + return SettingsSwitch( + value: isPrioritised, + onChanged: (value) { + cubit.setRearDisplayPrioritization(value); + }, + ); + }, + ), ), const Padding( padding: EdgeInsets.all(TADimens.PADDING_S_VALUE), @@ -70,67 +114,4 @@ class RearDisplaySettings extends SettingsSection { }, ); } - - Widget _rearDisplayStateSwitch( - BuildContext context, - RearDisplayConfigurationCubit cubit, - RearDisplayConfigurationState state, - ) { - if (state is RearDisplayConfigurationStateSettingsFetched) { - return Switch( - value: state.isRearDisplayEnabled, - onChanged: (bool value) { - cubit.setRearDisplayState(value); - }, - ); - } else if (state is RearDisplayConfigurationStateSettingsUpdateInProgress || - state is RearDisplayConfigurationStateLoading) { - return const CircularProgressIndicator(); - } else if (state is RearDisplayConfigurationStateError) { - return const Text("Service error"); - } - return const SizedBox.shrink(); - } - - Widget _rearDisplayPrioritySwitch( - BuildContext context, - RearDisplayConfigurationCubit cubit, - RearDisplayConfigurationState state, - ) { - if (state is RearDisplayConfigurationStateSettingsFetched) { - return Switch( - value: state.isRearDisplayPrioritised, - onChanged: (bool value) { - cubit.setRearDisplayPrioritization(value); - }, - ); - } else if (state is RearDisplayConfigurationStateSettingsUpdateInProgress || - state is RearDisplayConfigurationStateLoading) { - return const CircularProgressIndicator(); - } else if (state is RearDisplayConfigurationStateError) { - return const Text("Service error"); - } - return const SizedBox.shrink(); - } - - Widget _isPrimaryDisplaySwitch( - BuildContext context, - RearDisplayConfigurationCubit cubit, - RearDisplayConfigurationState state, - ) { - if (state is RearDisplayConfigurationStateSettingsFetched) { - return Switch( - value: state.isCurrentDisplayPrimary, - onChanged: (bool value) { - cubit.setDisplayType(isCurrentDisplayPrimary: value); - }, - ); - } else if (state is RearDisplayConfigurationStateSettingsUpdateInProgress || - state is RearDisplayConfigurationStateLoading) { - return const CircularProgressIndicator(); - } else if (state is RearDisplayConfigurationStateError) { - return const Text("Service error"); - } - return const SizedBox.shrink(); - } } diff --git a/lib/feature/settings/widget/settings_section.dart b/lib/feature/settings/widget/settings_section.dart index 2168059..2273d8e 100644 --- a/lib/feature/settings/widget/settings_section.dart +++ b/lib/feature/settings/widget/settings_section.dart @@ -6,11 +6,7 @@ abstract class SettingsSection extends StatelessWidget { final String name; final IconData icon; - const SettingsSection({ - super.key, - required this.name, - required this.icon, - }); + const SettingsSection({super.key, required this.name, required this.icon}); @nonVirtual @override @@ -18,9 +14,7 @@ abstract class SettingsSection extends StatelessWidget { return SingleChildScrollView( child: Scrollbar( child: Padding( - padding: const EdgeInsets.all( - TADimens.baseContentMargin, - ), + padding: const EdgeInsets.all(TADimens.baseContentMargin), child: Center( child: Container( constraints: const BoxConstraints( @@ -37,9 +31,7 @@ abstract class SettingsSection extends StatelessWidget { Widget body(BuildContext context); Widget get divider => const Padding( - padding: EdgeInsets.symmetric( - vertical: TADimens.baseContentMargin, - ), - child: Divider(), - ); + padding: EdgeInsets.symmetric(vertical: TADimens.baseContentMargin), + child: Divider(), + ); } diff --git a/lib/feature/settings/widget/settings_tile.dart b/lib/feature/settings/widget/settings_tile.dart index 0f434e1..a99ac31 100644 --- a/lib/feature/settings/widget/settings_tile.dart +++ b/lib/feature/settings/widget/settings_tile.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:tesla_android/common/ui/constants/ta_dimens.dart'; class SettingsTile extends StatelessWidget { final IconData icon; @@ -23,15 +22,7 @@ class SettingsTile extends StatelessWidget { leading: Icon(icon), title: Text(title), subtitle: subtitle != null ? Text(subtitle!) : const SizedBox.shrink(), - trailing: SizedBox( - width: dense ? TADimens.settingsTileTrailingWidthDense : TADimens - .settingsTileTrailingWidth, - child: Row( - children: [ - const Spacer(), - trailing, - ], - )), + trailing: Row(mainAxisSize: MainAxisSize.min, children: [trailing]), ); } } diff --git a/lib/feature/settings/widget/sound_settings.dart b/lib/feature/settings/widget/sound_settings.dart index e106ec6..6c6704a 100644 --- a/lib/feature/settings/widget/sound_settings.dart +++ b/lib/feature/settings/widget/sound_settings.dart @@ -1,22 +1,21 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:tesla_android/common/ui/constants/ta_colors.dart'; +import 'package:tesla_android/common/ui/components/settings_slider.dart'; +import 'package:tesla_android/common/ui/components/settings_switch.dart'; import 'package:tesla_android/common/ui/constants/ta_dimens.dart'; import 'package:tesla_android/feature/settings/bloc/audio_configuration_cubit.dart'; import 'package:tesla_android/feature/settings/bloc/audio_configuration_state.dart'; +import 'package:tesla_android/feature/settings/view_model/sound_settings_view_model.dart'; import 'package:tesla_android/feature/settings/widget/settings_section.dart'; import 'package:tesla_android/feature/settings/widget/settings_tile.dart'; class SoundSettings extends SettingsSection { - const SoundSettings({super.key}) - : super( - name: "Audio", - icon: Icons.speaker, - ); + const SoundSettings({super.key}) : super(name: "Audio", icon: Icons.speaker); @override Widget body(BuildContext context) { final cubit = BlocProvider.of(context); + final viewModel = SoundSettingsViewModel(); return BlocBuilder( builder: (context, state) { @@ -24,26 +23,29 @@ class SoundSettings extends SettingsSection { crossAxisAlignment: CrossAxisAlignment.start, children: [ SettingsTile( - icon: Icons.speaker, - title: 'Browser audio', - subtitle: 'Disable if you intend to use Bluetooth audio', - trailing: _audioStateSwitch(context, cubit, state)), + icon: Icons.speaker, + title: 'Browser audio', + subtitle: 'Disable if you intend to use Bluetooth audio', + trailing: _audioStateSwitch(context, cubit, state, viewModel), + ), divider, SettingsTile( icon: Icons.volume_down, title: 'Volume', - trailing: _audioStateSlider(context, cubit, state), + trailing: _audioStateSlider(context, cubit, state, viewModel), dense: false, ), const Padding( padding: EdgeInsets.all(TADimens.PADDING_S_VALUE), child: Text( - 'If you plan to use browser audio continuously in conjunction with video playback, it\'s essential to note that it can be bandwidth-intensive. To optimize your experience, you may want to consider pairing your car with the Tesla Android device over Bluetooth, particularly if your Tesla is equipped with MCU2.'), + 'If you plan to use browser audio continuously in conjunction with video playback, it\'s essential to note that it can be bandwidth-intensive. To optimize your experience, you may want to consider pairing your car with the Tesla Android device over Bluetooth, particularly if your Tesla is equipped with MCU2.', + ), ), const Padding( padding: EdgeInsets.all(TADimens.PADDING_S_VALUE), child: Text( - 'In case you encounter a situation where the browser in your Tesla fails to produce sound, a simple reboot of the vehicle should resolve the issue. Please note that this is a known issue with the browser itself.'), + 'In case you encounter a situation where the browser in your Tesla fails to produce sound, a simple reboot of the vehicle should resolve the issue. Please note that this is a known issue with the browser itself.', + ), ), ], ); @@ -51,54 +53,52 @@ class SoundSettings extends SettingsSection { ); } - Widget _audioStateSwitch(BuildContext context, AudioConfigurationCubit cubit, - AudioConfigurationState state) { - if (state is AudioConfigurationStateSettingsFetched) { - return Switch( - value: state.isEnabled, + Widget _audioStateSwitch( + BuildContext context, + AudioConfigurationCubit cubit, + AudioConfigurationState state, + SoundSettingsViewModel viewModel, + ) { + return viewModel.buildStateWidget( + state: state, + onFetched: () { + final isEnabled = viewModel.getAudioEnabled(state); + if (isEnabled == null) return const SizedBox.shrink(); + + return SettingsSwitch( + value: isEnabled, onChanged: (value) { cubit.setState(value); - }); - } else if (state is AudioConfigurationStateSettingsUpdateInProgress || - state is AudioConfigurationStateLoading) { - return const CircularProgressIndicator(); - } else if (state is AudioConfigurationStateError) { - return const Text("Service error"); - } - return const SizedBox.shrink(); + }, + ); + }, + ); } - Widget _audioStateSlider(BuildContext context, AudioConfigurationCubit cubit, - AudioConfigurationState state) { - if (state is AudioConfigurationStateSettingsFetched) { - return Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Text( - "${state.volume} %", - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: TAColors.settingsPrimaryColor), - ), - Slider( - divisions: 15, - min: 0, - max: 150, - value: state.volume.toDouble(), - onChanged: (double value) { - cubit.setVolume(value.toInt()); - }, - label: state.volume.toString(), - ), - ], - ); - } else if (state is AudioConfigurationStateSettingsUpdateInProgress || - state is AudioConfigurationStateLoading) { - return const CircularProgressIndicator(); - } else if (state is AudioConfigurationStateError) { - return const Text("Service error"); - } - return const SizedBox.shrink(); + Widget _audioStateSlider( + BuildContext context, + AudioConfigurationCubit cubit, + AudioConfigurationState state, + SoundSettingsViewModel viewModel, + ) { + return viewModel.buildStateWidget( + state: state, + onFetched: () { + final volume = viewModel.getVolume(state); + if (volume == null) return const SizedBox.shrink(); + + return SettingsSlider( + value: volume.toDouble(), + min: 0, + max: 150, + divisions: 15, + label: volume.toString(), + suffix: ' %', + onChanged: (double value) { + cubit.setVolume(value.toInt()); + }, + ); + }, + ); } } diff --git a/lib/feature/touchscreen/cubit/touchscreen_cubit.dart b/lib/feature/touchscreen/cubit/touchscreen_cubit.dart index a8603d1..77c7457 100644 --- a/lib/feature/touchscreen/cubit/touchscreen_cubit.dart +++ b/lib/feature/touchscreen/cubit/touchscreen_cubit.dart @@ -1,18 +1,19 @@ -import 'dart:js_interop'; - import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:injectable/injectable.dart'; import 'package:tesla_android/common/utils/logger.dart'; import 'package:tesla_android/feature/touchscreen/model/virtual_touchscreen_slot_state.dart'; import 'package:tesla_android/feature/touchscreen/model/virtual_touchscreen_command.dart'; -import 'package:web/web.dart' as web; +import 'package:tesla_android/feature/touchscreen/service/message_sender.dart'; @injectable class TouchscreenCubit extends Cubit with Logger { final List slotsState = VirtualTouchscreenSlotState.generateSlots(); - TouchscreenCubit() : super(false); + + final MessageSender _messageSender; + + TouchscreenCubit(this._messageSender) : super(false); @override Future close() { @@ -21,41 +22,59 @@ class TouchscreenCubit extends Cubit with Logger { } void handlePointerDownEvent( - PointerDownEvent event, BoxConstraints constraints, Size touchscreenSize) { - final scaledPointerPosition = - _scalePointerPosition(event.localPosition, constraints, touchscreenSize); + PointerDownEvent event, + BoxConstraints constraints, + Size touchscreenSize, + ) { + final scaledPointerPosition = _scalePointerPosition( + event.localPosition, + constraints, + touchscreenSize, + ); final slot = _getFirstUnusedSlot(); if (slot == null) return; slot.trackingId = event.pointer; slot.position = scaledPointerPosition; - log("Pointer down, assigned slot ${slot.slotIndex}, trackingId ${slot.trackingId}"); + log( + "Pointer down, assigned slot ${slot.slotIndex}, trackingId ${slot.trackingId}", + ); final command = VirtualTouchScreenCommand( - absMtSlot: slot.slotIndex, - absMtTrackingId: slot.trackingId, - absMtPositionX: slot.position.dx.toInt(), - absMtPositionY: slot.position.dy.toInt(), - synReport: true); + absMtSlot: slot.slotIndex, + absMtTrackingId: slot.trackingId, + absMtPositionX: slot.position.dx.toInt(), + absMtPositionY: slot.position.dy.toInt(), + synReport: true, + ); sendCommand(command); } void handlePointerMoveEvent( - PointerMoveEvent event, BoxConstraints constraints, Size touchscreenSize) { - final scaledPointerPosition = - _scalePointerPosition(event.localPosition, constraints, touchscreenSize); + PointerMoveEvent event, + BoxConstraints constraints, + Size touchscreenSize, + ) { + final scaledPointerPosition = _scalePointerPosition( + event.localPosition, + constraints, + touchscreenSize, + ); final slot = _getSlotFromTrackingId(event.pointer); if (slot == null) return; slot.position = scaledPointerPosition; - log("Pointer move, matched slot ${slot.slotIndex}, trackingId ${slot.trackingId}"); + log( + "Pointer move, matched slot ${slot.slotIndex}, trackingId ${slot.trackingId}", + ); final command = VirtualTouchScreenCommand( - absMtSlot: slot.slotIndex, - absMtPositionX: slot.position.dx.toInt(), - absMtPositionY: slot.position.dy.toInt(), - synReport: true); + absMtSlot: slot.slotIndex, + absMtPositionX: slot.position.dx.toInt(), + absMtPositionY: slot.position.dy.toInt(), + synReport: true, + ); sendCommand(command); } @@ -64,14 +83,17 @@ class TouchscreenCubit extends Cubit with Logger { final slot = _getSlotFromTrackingId(event.pointer); if (slot == null) return; - log("Pointer up, matched slot ${slot.slotIndex}, trackingId ${slot.trackingId}"); + log( + "Pointer up, matched slot ${slot.slotIndex}, trackingId ${slot.trackingId}", + ); slot.trackingId = -1; final command = VirtualTouchScreenCommand( - absMtSlot: slot.slotIndex, - absMtTrackingId: slot.trackingId, - synReport: true); + absMtSlot: slot.slotIndex, + absMtTrackingId: slot.trackingId, + synReport: true, + ); sendCommand(command); } @@ -94,7 +116,11 @@ class TouchscreenCubit extends Cubit with Logger { return null; } - Offset _scalePointerPosition(Offset position, BoxConstraints constraints, Size touchscreenSize) { + Offset _scalePointerPosition( + Offset position, + BoxConstraints constraints, + Size touchscreenSize, + ) { final scaleX = touchscreenSize.width / constraints.maxWidth; final scaleY = touchscreenSize.height / constraints.maxHeight; @@ -113,8 +139,12 @@ class TouchscreenCubit extends Cubit with Logger { void resetTouchScreen() { List commands = []; for (final slot in VirtualTouchscreenSlotState.generateSlots()) { - commands.add(VirtualTouchScreenCommand( - absMtSlot: slot.slotIndex, absMtTrackingId: slot.trackingId)); + commands.add( + VirtualTouchScreenCommand( + absMtSlot: slot.slotIndex, + absMtTrackingId: slot.trackingId, + ), + ); } sendCommands(commands: commands); } @@ -126,10 +156,10 @@ class TouchscreenCubit extends Cubit with Logger { for (final command in commands) { message += command.build(); } - web.window.postMessage(message.toJS, '*'.toJS); + _messageSender.postMessage(message, '*'); } void sendCommand(VirtualTouchScreenCommand command) { - web.window.postMessage(command.build().toJS, '*'.toJS); + _messageSender.postMessage(command.build(), '*'); } } diff --git a/lib/feature/touchscreen/model/virtual_touchscreen_command.dart b/lib/feature/touchscreen/model/virtual_touchscreen_command.dart index 4cbbf1d..e311edd 100644 --- a/lib/feature/touchscreen/model/virtual_touchscreen_command.dart +++ b/lib/feature/touchscreen/model/virtual_touchscreen_command.dart @@ -29,4 +29,4 @@ class VirtualTouchScreenCommand { if (synReport) command += 'e 0\nS 0\n'; return command; } -} \ No newline at end of file +} diff --git a/lib/feature/touchscreen/model/virtual_touchscreen_slot_state.dart b/lib/feature/touchscreen/model/virtual_touchscreen_slot_state.dart index b5446d9..19dd159 100644 --- a/lib/feature/touchscreen/model/virtual_touchscreen_slot_state.dart +++ b/lib/feature/touchscreen/model/virtual_touchscreen_slot_state.dart @@ -6,8 +6,8 @@ class VirtualTouchscreenSlotState { Offset position; VirtualTouchscreenSlotState.initial({required this.slotIndex}) - : trackingId = -1, - position = Offset.zero; + : trackingId = -1, + position = Offset.zero; static List generateSlots() { return List.generate( diff --git a/lib/feature/touchscreen/service/message_sender.dart b/lib/feature/touchscreen/service/message_sender.dart new file mode 100644 index 0000000..0a36c7f --- /dev/null +++ b/lib/feature/touchscreen/service/message_sender.dart @@ -0,0 +1,3 @@ +abstract class MessageSender { + void postMessage(String message, String targetOrigin); +} diff --git a/lib/feature/touchscreen/service/message_sender_factory.dart b/lib/feature/touchscreen/service/message_sender_factory.dart new file mode 100644 index 0000000..3ec1228 --- /dev/null +++ b/lib/feature/touchscreen/service/message_sender_factory.dart @@ -0,0 +1,7 @@ +import 'package:tesla_android/feature/touchscreen/service/message_sender.dart'; +import 'message_sender_stub.dart' + if (dart.library.js_interop) 'window_message_sender.dart'; + +class MessageSenderFactory { + static MessageSender create() => createMessageSender(); +} diff --git a/lib/feature/touchscreen/service/message_sender_stub.dart b/lib/feature/touchscreen/service/message_sender_stub.dart new file mode 100644 index 0000000..24d9c22 --- /dev/null +++ b/lib/feature/touchscreen/service/message_sender_stub.dart @@ -0,0 +1,10 @@ +import 'package:tesla_android/feature/touchscreen/service/message_sender.dart'; + +class MessageSenderStub implements MessageSender { + @override + void postMessage(String message, String targetOrigin) { + // No-op on VM + } +} + +MessageSender createMessageSender() => MessageSenderStub(); diff --git a/lib/feature/touchscreen/service/window_message_sender.dart b/lib/feature/touchscreen/service/window_message_sender.dart new file mode 100644 index 0000000..be6939d --- /dev/null +++ b/lib/feature/touchscreen/service/window_message_sender.dart @@ -0,0 +1,12 @@ +import 'package:tesla_android/feature/touchscreen/service/message_sender.dart'; +import 'package:web/web.dart' as web; +import 'dart:js_interop'; + +class WindowMessageSender implements MessageSender { + @override + void postMessage(String message, String targetOrigin) { + web.window.postMessage(message.toJS, targetOrigin.toJS); + } +} + +MessageSender createMessageSender() => WindowMessageSender(); diff --git a/lib/feature/touchscreen/touchscreen_view.dart b/lib/feature/touchscreen/touchscreen_view.dart index f9fe0d6..dcbeb7a 100644 --- a/lib/feature/touchscreen/touchscreen_view.dart +++ b/lib/feature/touchscreen/touchscreen_view.dart @@ -1,4 +1,3 @@ - import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:tesla_android/feature/touchscreen/cubit/touchscreen_cubit.dart'; @@ -6,51 +5,50 @@ import 'package:tesla_android/feature/touchscreen/cubit/touchscreen_cubit.dart'; class TouchScreenView extends StatelessWidget { final Size displaySize; - const TouchScreenView({ - super.key, - required this.displaySize, - }); + const TouchScreenView({super.key, required this.displaySize}); @override Widget build(BuildContext context) { final touchScreenCubit = BlocProvider.of(context); - return LayoutBuilder(builder: (context, constraints) { - return Listener( - onPointerDown: (event) { - _handlePointerEvent( - cubit: touchScreenCubit, - event: event, - constraints: constraints, - touchscreenSize: displaySize, - ); - }, - onPointerMove: (event) { - _handlePointerEvent( - cubit: touchScreenCubit, - event: event, - constraints: constraints, - touchscreenSize: displaySize, - ); - }, - onPointerCancel: (event) { - _handlePointerEvent( - cubit: touchScreenCubit, - event: event, - constraints: constraints, - touchscreenSize: displaySize, - ); - }, - onPointerUp: (event) { - _handlePointerEvent( - cubit: touchScreenCubit, - event: event, - constraints: constraints, - touchscreenSize: displaySize, - ); - }, - child: Container(color: Colors.transparent,), - ); - }); + return LayoutBuilder( + builder: (context, constraints) { + return Listener( + onPointerDown: (event) { + _handlePointerEvent( + cubit: touchScreenCubit, + event: event, + constraints: constraints, + touchscreenSize: displaySize, + ); + }, + onPointerMove: (event) { + _handlePointerEvent( + cubit: touchScreenCubit, + event: event, + constraints: constraints, + touchscreenSize: displaySize, + ); + }, + onPointerCancel: (event) { + _handlePointerEvent( + cubit: touchScreenCubit, + event: event, + constraints: constraints, + touchscreenSize: displaySize, + ); + }, + onPointerUp: (event) { + _handlePointerEvent( + cubit: touchScreenCubit, + event: event, + constraints: constraints, + touchscreenSize: displaySize, + ); + }, + child: Container(color: Colors.transparent), + ); + }, + ); } void _handlePointerEvent({ @@ -67,4 +65,4 @@ class TouchScreenView extends StatelessWidget { cubit.handlePointerUpEvent(event, constraints); } } -} \ No newline at end of file +} diff --git a/lib/main.dart b/lib/main.dart index 25679d3..2d09409 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,3 @@ - import 'package:flutter/material.dart'; import 'package:tesla_android/common/di/ta_locator.dart'; import 'package:tesla_android/common/navigation/ta_page_factory.dart'; @@ -8,9 +7,7 @@ import 'package:tesla_android/common/utils/logger.dart'; Future main() async { await configureTADependencies(); - runApp( - TeslaAndroid(), - ); + runApp(TeslaAndroid()); } class TeslaAndroid extends StatelessWidget with Logger { @@ -23,21 +20,26 @@ class TeslaAndroid extends StatelessWidget with Logger { MediaQueryData windowData = MediaQueryData.fromView(View.of(context)); return MediaQuery( - data: windowData.copyWith(devicePixelRatio: 1.0, textScaler: const TextScaler.linear(1.5)), + data: windowData.copyWith( + devicePixelRatio: 1.0, + textScaler: const TextScaler.linear(1.5), + ), child: MaterialApp( debugShowCheckedModeBanner: false, navigatorKey: getIt>(), title: 'Tesla Android', theme: ThemeData( - brightness: Brightness.light, - useMaterial3: true, - colorSchemeSeed: TAColors.settingsPrimaryColor, - fontFamily: 'Roboto'), + brightness: Brightness.light, + useMaterial3: true, + colorSchemeSeed: TAColors.settingsPrimaryColor, + fontFamily: 'Roboto', + ), darkTheme: ThemeData( - brightness: Brightness.dark, - useMaterial3: true, - colorSchemeSeed: TAColors.settingsPrimaryColor, - fontFamily: 'Roboto'), + brightness: Brightness.dark, + useMaterial3: true, + colorSchemeSeed: TAColors.settingsPrimaryColor, + fontFamily: 'Roboto', + ), themeMode: ThemeMode.system, initialRoute: _pageFactory.initialRoute, routes: _pageFactory.getRoutes(), diff --git a/pubspec.lock b/pubspec.lock index 0f0bafc..85d1cf7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.0.0" + bloc_test: + dependency: "direct dev" + description: + name: bloc_test + sha256: "1dd549e58be35148bc22a9135962106aa29334bc1e3f285994946a1057b29d7b" + url: "https://pub.dev" + source: hosted + version: "10.0.0" boolean_selector: dependency: transitive description: @@ -129,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.4" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" clock: dependency: transitive description: @@ -161,6 +177,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" crypto: dependency: transitive description: @@ -177,6 +201,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.1" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" + url: "https://pub.dev" + source: hosted + version: "0.4.1" dio: dependency: "direct main" description: @@ -360,6 +392,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" json_annotation: dependency: "direct main" description: @@ -452,10 +492,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -464,6 +504,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mockito: + dependency: "direct dev" + description: + name: mockito + sha256: "2314cbe9165bcd16106513df9cf3c3224713087f09723b128928dc11a4379f99" + url: "https://pub.dev" + source: hosted + version: "5.5.0" + mocktail: + dependency: transitive + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" + source: hosted + version: "1.0.4" nested: dependency: transitive description: @@ -472,6 +528,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" package_config: dependency: transitive description: @@ -728,6 +792,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" shelf_web_socket: dependency: transitive description: @@ -757,6 +837,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.7" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" source_span: dependency: transitive description: @@ -805,14 +901,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + url: "https://pub.dev" + source: hosted + version: "1.26.3" test_api: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" + test_core: + dependency: transitive + description: + name: test_core + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + url: "https://pub.dev" + source: hosted + version: "0.6.12" timing: dependency: transitive description: @@ -949,6 +1061,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.1" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" win32: dependency: transitive description: @@ -974,5 +1094,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: "3.9.0" - flutter: "3.35.0" + dart: ">=3.9.0 <4.0.0" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index 2ca3b61..8de3598 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,8 +6,8 @@ publish_to: 'none' version: 2025.46.1 environment: - flutter: "3.35.0" - sdk: "3.9.0" + flutter: ">=3.35.0" + sdk: ">=3.9.0 <4.0.0" dependencies: flutter: @@ -39,6 +39,8 @@ dev_dependencies: build_runner: 2.7.1 injectable_generator: 2.8.1 retrofit_generator: 10.0.5 + mockito: ^5.4.4 + bloc_test: ^10.0.0 flutter: assets: diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh new file mode 100755 index 0000000..1e80836 --- /dev/null +++ b/scripts/run_tests.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# Test runner script for Tesla Android Flutter app +# Runs different test suites based on environment + +set -e # Exit on error + +# Colors for output +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +echo -e "${GREEN}Tesla Android Test Runner${NC}" +echo "================================" +echo "" + +# Function to run VM tests (fast) +run_vm_tests() { + echo -e "${YELLOW}Running All Tests in VM${NC}" + # Runs all tests found in the test/ directory + # This includes unit, widget, and integration tests + flutter test + + echo -e "${GREEN}✓ VM Tests Complete${NC}" +} + +# Function to run browser tests (slower) +run_browser_tests() { + echo -e "${YELLOW}Running Unit Tests in Chrome${NC}" + # Runs unit tests on Chrome to verify web compatibility + # Note: Widget and Integration tests are skipped to save time/avoid io dependencies + flutter test --platform chrome test/unit/ + + echo -e "${GREEN}✓ Browser Tests Complete${NC}" +} + +# Function to run all tests +run_all_tests() { + # Default to just VM tests as they cover the codebase + # and are significantly faster. + run_vm_tests +} + +# Parse command line arguments +case "${1:-all}" in + vm) + run_vm_tests + ;; + browser) + run_browser_tests + ;; + all) + run_all_tests + ;; + *) + echo -e "${RED}Unknown option: $1${NC}" + echo "Usage: $0 {vm|browser|all}" + echo "" + echo " vm - Run fast VM tests (all tests)" + echo " browser - Run unit tests in Chrome" + echo " all - Run all standard tests (currently alias for vm)" + exit 1 + ;; +esac + +echo "" +echo -e "${GREEN}Test execution completed successfully!${NC}" diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..c11583e --- /dev/null +++ b/test/README.md @@ -0,0 +1,188 @@ +# Testing Guide + +## Quick Start + +```bash +# Run all tests (Fast VM tests) +flutter test + +# With coverage +flutter test --coverage +genhtml coverage/lcov.info -o coverage/html +open coverage/html/index.html +``` + +--- + +## Test Status + +**Total: 340 tests** ✅ All passing (VM) + +**Coverage**: 80.1% + +**Breakdown**: +- Base view model tests: 14 +- Service tests: 13 +- View model tests: 41 +- Widget tests: 24 +- Integration tests: 24 +- Cubit tests: 202 + +--- + +## Test Architecture + +### MVVM Pattern (Settings) + +View models extend `BaseSettingsViewModel` for consistent state handling: + +```dart +class SoundSettingsViewModel extends BaseSettingsViewModel { + @override + bool isLoadingState(AudioConfigurationState state) => + state is AudioConfigurationStateLoading; + // ... +} +``` + +**View Models**: +- `DisplaySettingsViewModel` +- `HotspotSettingsViewModel` +- `SoundSettingsViewModel` +- `RearDisplaySettingsViewModel` + +### Reusable Components + +Type-safe UI components in `lib/common/ui/components/`: +- `SettingsSwitch` - Boolean toggles +- `SettingsDropdown` - Generic dropdowns +- `SettingsSlider` - Numeric sliders +- `SettingsErrorWidget` - Standardized error display + +### Test Helpers + +Located in `test/helpers/`: +- `SettingsTestHelpers` - Widget test utilities +- `CubitBuilders` - Mock cubit setup with defaults +- `TestFixtures` - Sample data and builders +- `setupGetItMocks()` - DI mocking + +--- + +## Integration Tests + +**24 tests** covering critical flows: + +```bash +flutter test test/integration/ +``` + +**Test Files**: +- `settings_flow_test.dart` - HotspotSettings banner (3) +- `device_info_flow_test.dart` - Device display (2) +- `gps_flow_test.dart` - GPS toggle (3) +- `rear_display_flow_test.dart` - Multi-switch (5) +- `audio_flow_test.dart` - Volume/enable (7) +- `connectivity_flow_test.dart` - Connection states (4) + +--- + +## Factory Tests + +Platform abstraction factories are tested in `test/unit/factories/`: + +```bash +flutter test test/unit/factories/ +``` + +**Covered Factories**: +- `AudioServiceFactory` +- `WindowServiceFactory` +- `MessageSenderFactory` +- `FlavorFactory` + +--- + +## Browser & Web Testing + +**Strategy**: VM-First. +Dependencies like `dart:html` or `package:web` are abstracted behind **Factory Patterns** and **Conditional Imports** to allow fast, reliable VM testing. + +### Architecture +- **Stubs**: `WebAudioService`, `WebWindowService`, etc., use stubs for VM execution. +- **Factories**: `AudioServiceFactory`, `WindowServiceFactory` select the correct implementation at runtime. + +### Running Browser Tests +If you need to validate actual browser behavior (e.g., JS interop): + +```bash +flutter test --platform chrome test/path/to/test.dart +``` + +--- + +## Best Practices + +### Testing Patterns + +```dart +// View model test +test('extracts value', () { + expect(viewModel.getValue(state), expectedValue); +}); + +// Integration test +testWidgets('user interaction', (tester) async { + when(mockCubit.state).thenReturn(State(value: false)); + await tester.tap(find.byType(Switch)); + verify(mockCubit.setValue(true)).called(1); +}); +``` + +### Guidelines + +- ✅ Use view models for business logic +- ✅ Use `SettingsTestHelpers` for widget tests +- ✅ Use `CubitBuilders` for mock setup +- ✅ Mock dependencies with `@GenerateMocks` +- ✅ Use `bloc_test` for cubit testing +- ✅ Write integration tests for user flows +- ✅ Extend `BaseSettingsViewModel` for new settings + +--- + +## Troubleshooting + +```dart +// Ensure async/await in bloc tests +blocTest('test', act: (cubit) async => await cubit.doSomething()); +``` + +**Coverage not updating:** +```bash +flutter clean +flutter test --coverage +``` + +**Mock generation:** +```bash +flutter packages pub run build_runner build --delete-conflicting-outputs +``` + +--- + +## Using Makefile + +```bash +make test # Run all tests +make coverage # Generate and open coverage report +make quick-check # Format, analyze, and test +``` + +--- + +## Resources + +- [Flutter Testing](https://docs.flutter.dev/testing) +- [bloc_test](https://pub.dev/packages/bloc_test) +- [mockito](https://pub.dev/packages/mockito) diff --git a/test/helpers/cubit_builders.dart b/test/helpers/cubit_builders.dart new file mode 100644 index 0000000..48ce6e8 --- /dev/null +++ b/test/helpers/cubit_builders.dart @@ -0,0 +1,194 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/feature/connectivityCheck/model/connectivity_state.dart'; +import 'package:tesla_android/feature/display/cubit/display_state.dart'; +import 'package:tesla_android/feature/display/model/remote_display_state.dart'; +import 'package:tesla_android/feature/home/cubit/ota_update_state.dart'; +import 'package:tesla_android/feature/settings/bloc/audio_configuration_state.dart'; +import 'package:tesla_android/feature/settings/bloc/device_info_state.dart'; +import 'package:tesla_android/feature/settings/bloc/display_configuration_state.dart'; +import 'package:tesla_android/feature/settings/bloc/gps_configuration_state.dart'; +import 'package:tesla_android/feature/settings/bloc/rear_display_configuration_state.dart'; +import 'package:tesla_android/feature/settings/bloc/system_configuration_state.dart'; +import 'package:tesla_android/feature/settings/model/device_info.dart'; +import 'package:tesla_android/feature/settings/model/system_configuration_response_body.dart'; + +import 'mock_cubits.mocks.dart'; + +/// Shared builders for creating configured mock cubits +/// Reduces duplication across widget and integration tests +class CubitBuilders { + /// Creates a fully configured mock ConnectivityCheckCubit + static MockConnectivityCheckCubit buildConnectivityCheckCubit({ + ConnectivityState? state, + Stream? stream, + }) { + final cubit = MockConnectivityCheckCubit(); + when(cubit.state).thenReturn(state ?? ConnectivityState.backendAccessible); + when(cubit.stream).thenAnswer( + (_) => + stream ?? Stream.value(state ?? ConnectivityState.backendAccessible), + ); + return cubit; + } + + /// Creates a fully configured mock DisplayCubit + static MockDisplayCubit buildDisplayCubit({ + DisplayState? state, + Stream? stream, + }) { + final cubit = MockDisplayCubit(); + when(cubit.state).thenReturn( + state ?? + DisplayStateNormal( + viewSize: const Size(1280, 720), + adjustedSize: const Size(1280, 720), + rendererType: DisplayRendererType.mjpeg, + ), + ); + when(cubit.stream).thenAnswer((_) => stream ?? const Stream.empty()); + when(cubit.onWindowSizeChanged(any)).thenAnswer((_) async {}); + return cubit; + } + + /// Creates a fully configured mock AudioConfigurationCubit + static MockAudioConfigurationCubit buildAudioConfigurationCubit({ + AudioConfigurationState? state, + Stream? stream, + }) { + final cubit = MockAudioConfigurationCubit(); + when(cubit.state).thenReturn( + state ?? + AudioConfigurationStateSettingsFetched(isEnabled: true, volume: 100), + ); + when(cubit.stream).thenAnswer((_) => stream ?? const Stream.empty()); + when(cubit.fetchConfiguration()).thenAnswer((_) async {}); + return cubit; + } + + /// Creates a fully configured mock OTAUpdateCubit + static MockOTAUpdateCubit buildOTAUpdateCubit({ + OTAUpdateState? state, + Stream? stream, + }) { + final cubit = MockOTAUpdateCubit(); + when(cubit.state).thenReturn(state ?? OTAUpdateStateInitial()); + when(cubit.stream).thenAnswer((_) => stream ?? const Stream.empty()); + return cubit; + } + + /// Creates a fully configured mock TouchscreenCubit + static MockTouchscreenCubit buildTouchscreenCubit({ + bool? state, + Stream? stream, + }) { + final cubit = MockTouchscreenCubit(); + when(cubit.state).thenReturn(state ?? false); + when(cubit.stream).thenAnswer((_) => stream ?? const Stream.empty()); + return cubit; + } + + /// Creates a fully configured mock DisplayConfigurationCubit + static MockDisplayConfigurationCubit buildDisplayConfigurationCubit({ + DisplayConfigurationState? state, + Stream? stream, + }) { + final cubit = MockDisplayConfigurationCubit(); + when(cubit.state).thenReturn( + state ?? + DisplayConfigurationStateSettingsFetched( + resolutionPreset: DisplayResolutionModePreset.res720p, + renderer: DisplayRendererType.mjpeg, + isResponsive: true, + quality: DisplayQualityPreset.quality60, + refreshRate: DisplayRefreshRatePreset.refresh60hz, + ), + ); + when(cubit.stream).thenAnswer((_) => stream ?? const Stream.empty()); + return cubit; + } + + /// Creates a fully configured mock RearDisplayConfigurationCubit + static MockRearDisplayConfigurationCubit buildRearDisplayConfigurationCubit({ + RearDisplayConfigurationState? state, + Stream? stream, + }) { + final cubit = MockRearDisplayConfigurationCubit(); + when(cubit.state).thenReturn( + state ?? + RearDisplayConfigurationStateSettingsFetched( + isRearDisplayEnabled: false, + isRearDisplayPrioritised: false, + isCurrentDisplayPrimary: true, + ), + ); + when(cubit.stream).thenAnswer((_) => stream ?? const Stream.empty()); + return cubit; + } + + /// Creates a fully configured mock SystemConfigurationCubit + static MockSystemConfigurationCubit buildSystemConfigurationCubit({ + SystemConfigurationState? state, + Stream? stream, + }) { + final cubit = MockSystemConfigurationCubit(); + when(cubit.state).thenReturn( + state ?? + SystemConfigurationStateSettingsFetched( + currentConfiguration: SystemConfigurationResponseBody( + isEnabledFlag: 1, + bandType: 1, + channel: 6, + channelWidth: 2, + isOfflineModeEnabledFlag: 0, + isOfflineModeTelemetryEnabledFlag: 0, + isOfflineModeTeslaFirmwareDownloadsEnabledFlag: 0, + browserAudioIsEnabled: 1, + browserAudioVolume: 100, + isGPSEnabled: 1, + ), + ), + ); + when(cubit.stream).thenAnswer((_) => stream ?? const Stream.empty()); + return cubit; + } + + /// Creates a fully configured mock GPSConfigurationCubit + static MockGPSConfigurationCubit buildGPSConfigurationCubit({ + GPSConfigurationState? state, + Stream? stream, + }) { + final cubit = MockGPSConfigurationCubit(); + when( + cubit.state, + ).thenReturn(state ?? GPSConfigurationStateLoaded(isGPSEnabled: true)); + when(cubit.stream).thenAnswer((_) => stream ?? const Stream.empty()); + return cubit; + } + + /// Creates a fully configured mock DeviceInfoCubit + static MockDeviceInfoCubit buildDeviceInfoCubit({ + DeviceInfoState? state, + Stream? stream, + }) { + final cubit = MockDeviceInfoCubit(); + when(cubit.state).thenReturn( + state ?? + DeviceInfoStateLoaded( + deviceInfo: const DeviceInfo( + cpuTemperature: 40, + serialNumber: "123", + deviceModel: "Raspberry Pi 4", + isCarPlayDetected: 0, + isModemDetected: 0, + releaseType: "stable", + otaUrl: "http://example.com", + isGPSEnabled: 1, + ), + ), + ); + when(cubit.stream).thenAnswer((_) => stream ?? const Stream.empty()); + return cubit; + } +} diff --git a/test/helpers/cubit_test_helpers.dart b/test/helpers/cubit_test_helpers.dart new file mode 100644 index 0000000..c3addb6 --- /dev/null +++ b/test/helpers/cubit_test_helpers.dart @@ -0,0 +1,51 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// Shared test helpers for common cubit testing patterns +/// Reduces boilerplate in individual cubit test files +class CubitTestHelpers { + /// Tests common cubit behavior: initial state and proper cleanup + /// + /// Usage: + /// ```dart + /// testCubitBasics( + /// buildCubit: () => MyCubit(mockDep), + /// expectedInitialState: MyStateInitial(), + /// ); + /// ``` + static void testCubitBasics, S>({ + required T Function() buildCubit, + required S expectedInitialState, + String? description, + }) { + group(description ?? 'Cubit basics', () { + test('initial state is correct', () async { + final cubit = buildCubit(); + expect(cubit.state, equals(expectedInitialState)); + await cubit.close(); + }); + + test('close works properly', () async { + final cubit = buildCubit(); + await cubit.close(); + expect(cubit.isClosed, true); + }); + }); + } + + /// Tests that a cubit properly handles async operations + /// Useful for testing repository call patterns + static void testAsyncOperation, S>({ + required T Function() buildCubit, + required Future Function(T cubit) act, + required void Function() verify, + String? description, + }) { + test(description ?? 'async operation completes', () async { + final cubit = buildCubit(); + await act(cubit); + verify(); + await cubit.close(); + }); + } +} diff --git a/test/helpers/mock_cubits.dart b/test/helpers/mock_cubits.dart new file mode 100644 index 0000000..ee39568 --- /dev/null +++ b/test/helpers/mock_cubits.dart @@ -0,0 +1,30 @@ +import 'package:mockito/annotations.dart'; +import 'package:tesla_android/feature/settings/bloc/system_configuration_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/audio_configuration_cubit.dart'; + +import 'package:tesla_android/feature/settings/bloc/display_configuration_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/rear_display_configuration_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/gps_configuration_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/device_info_cubit.dart'; +import 'package:tesla_android/feature/connectivityCheck/cubit/connectivity_check_cubit.dart'; +import 'package:tesla_android/feature/display/cubit/display_cubit.dart'; +import 'package:tesla_android/feature/home/cubit/ota_update_cubit.dart'; +import 'package:tesla_android/feature/touchscreen/cubit/touchscreen_cubit.dart'; +import 'package:tesla_android/common/service/audio_service.dart'; +import 'package:tesla_android/feature/releaseNotes/cubit/release_notes_cubit.dart'; + +@GenerateMocks([ + SystemConfigurationCubit, + AudioConfigurationCubit, + DisplayConfigurationCubit, + RearDisplayConfigurationCubit, + GPSConfigurationCubit, + DeviceInfoCubit, + ConnectivityCheckCubit, + DisplayCubit, + OTAUpdateCubit, + TouchscreenCubit, + AudioService, + ReleaseNotesCubit, +]) +void main() {} diff --git a/test/helpers/mock_cubits.mocks.dart b/test/helpers/mock_cubits.mocks.dart new file mode 100644 index 0000000..ba0341f --- /dev/null +++ b/test/helpers/mock_cubits.mocks.dart @@ -0,0 +1,1344 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in tesla_android/test/helpers/mock_cubits.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i12; +import 'dart:ui' as _i24; + +import 'package:flutter/cupertino.dart' as _i28; +import 'package:flutter_bloc/flutter_bloc.dart' as _i14; +import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i31; +import 'package:tesla_android/common/service/audio_service.dart' as _i30; +import 'package:tesla_android/feature/connectivityCheck/cubit/connectivity_check_cubit.dart' + as _i21; +import 'package:tesla_android/feature/connectivityCheck/model/connectivity_state.dart' + as _i22; +import 'package:tesla_android/feature/display/cubit/display_cubit.dart' as _i23; +import 'package:tesla_android/feature/display/cubit/display_state.dart' as _i8; +import 'package:tesla_android/feature/display/model/remote_display_state.dart' + as _i17; +import 'package:tesla_android/feature/home/cubit/ota_update_cubit.dart' as _i25; +import 'package:tesla_android/feature/home/cubit/ota_update_state.dart' as _i9; +import 'package:tesla_android/feature/releaseNotes/cubit/release_notes_cubit.dart' + as _i32; +import 'package:tesla_android/feature/releaseNotes/cubit/release_notes_state.dart' + as _i10; +import 'package:tesla_android/feature/releaseNotes/model/changelog_item.dart' + as _i34; +import 'package:tesla_android/feature/releaseNotes/model/version.dart' as _i33; +import 'package:tesla_android/feature/settings/bloc/audio_configuration_cubit.dart' + as _i15; +import 'package:tesla_android/feature/settings/bloc/audio_configuration_state.dart' + as _i3; +import 'package:tesla_android/feature/settings/bloc/device_info_cubit.dart' + as _i20; +import 'package:tesla_android/feature/settings/bloc/device_info_state.dart' + as _i7; +import 'package:tesla_android/feature/settings/bloc/display_configuration_cubit.dart' + as _i16; +import 'package:tesla_android/feature/settings/bloc/display_configuration_state.dart' + as _i4; +import 'package:tesla_android/feature/settings/bloc/gps_configuration_cubit.dart' + as _i19; +import 'package:tesla_android/feature/settings/bloc/gps_configuration_state.dart' + as _i6; +import 'package:tesla_android/feature/settings/bloc/rear_display_configuration_cubit.dart' + as _i18; +import 'package:tesla_android/feature/settings/bloc/rear_display_configuration_state.dart' + as _i5; +import 'package:tesla_android/feature/settings/bloc/system_configuration_cubit.dart' + as _i11; +import 'package:tesla_android/feature/settings/bloc/system_configuration_state.dart' + as _i2; +import 'package:tesla_android/feature/settings/model/softap_band_type.dart' + as _i13; +import 'package:tesla_android/feature/touchscreen/cubit/touchscreen_cubit.dart' + as _i26; +import 'package:tesla_android/feature/touchscreen/model/virtual_touchscreen_command.dart' + as _i29; +import 'package:tesla_android/feature/touchscreen/model/virtual_touchscreen_slot_state.dart' + as _i27; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeSystemConfigurationState_0 extends _i1.SmartFake + implements _i2.SystemConfigurationState { + _FakeSystemConfigurationState_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeAudioConfigurationState_1 extends _i1.SmartFake + implements _i3.AudioConfigurationState { + _FakeAudioConfigurationState_1(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeDisplayConfigurationState_2 extends _i1.SmartFake + implements _i4.DisplayConfigurationState { + _FakeDisplayConfigurationState_2(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeRearDisplayConfigurationState_3 extends _i1.SmartFake + implements _i5.RearDisplayConfigurationState { + _FakeRearDisplayConfigurationState_3( + Object parent, + Invocation parentInvocation, + ) : super(parent, parentInvocation); +} + +class _FakeGPSConfigurationState_4 extends _i1.SmartFake + implements _i6.GPSConfigurationState { + _FakeGPSConfigurationState_4(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeDeviceInfoState_5 extends _i1.SmartFake + implements _i7.DeviceInfoState { + _FakeDeviceInfoState_5(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeDisplayState_6 extends _i1.SmartFake implements _i8.DisplayState { + _FakeDisplayState_6(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeOTAUpdateState_7 extends _i1.SmartFake + implements _i9.OTAUpdateState { + _FakeOTAUpdateState_7(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeReleaseNotesState_8 extends _i1.SmartFake + implements _i10.ReleaseNotesState { + _FakeReleaseNotesState_8(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +/// A class which mocks [SystemConfigurationCubit]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockSystemConfigurationCubit extends _i1.Mock + implements _i11.SystemConfigurationCubit { + MockSystemConfigurationCubit() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.SystemConfigurationState get state => + (super.noSuchMethod( + Invocation.getter(#state), + returnValue: _FakeSystemConfigurationState_0( + this, + Invocation.getter(#state), + ), + ) + as _i2.SystemConfigurationState); + + @override + _i12.Stream<_i2.SystemConfigurationState> get stream => + (super.noSuchMethod( + Invocation.getter(#stream), + returnValue: _i12.Stream<_i2.SystemConfigurationState>.empty(), + ) + as _i12.Stream<_i2.SystemConfigurationState>); + + @override + bool get isClosed => + (super.noSuchMethod(Invocation.getter(#isClosed), returnValue: false) + as bool); + + @override + _i12.Future fetchConfiguration() => + (super.noSuchMethod( + Invocation.method(#fetchConfiguration, []), + returnValue: _i12.Future.value(), + returnValueForMissingStub: _i12.Future.value(), + ) + as _i12.Future); + + @override + void updateSoftApBand(_i13.SoftApBandType? newBand) => super.noSuchMethod( + Invocation.method(#updateSoftApBand, [newBand]), + returnValueForMissingStub: null, + ); + + @override + void updateSoftApState(bool? isEnabled) => super.noSuchMethod( + Invocation.method(#updateSoftApState, [isEnabled]), + returnValueForMissingStub: null, + ); + + @override + void updateOfflineModeState(bool? isEnabled) => super.noSuchMethod( + Invocation.method(#updateOfflineModeState, [isEnabled]), + returnValueForMissingStub: null, + ); + + @override + void updateOfflineModeTelemetryState(bool? isEnabled) => super.noSuchMethod( + Invocation.method(#updateOfflineModeTelemetryState, [isEnabled]), + returnValueForMissingStub: null, + ); + + @override + void updateOfflineModeTeslaFirmwareDownloadsState(bool? isEnabled) => + super.noSuchMethod( + Invocation.method(#updateOfflineModeTeslaFirmwareDownloadsState, [ + isEnabled, + ]), + returnValueForMissingStub: null, + ); + + @override + void applySystemConfiguration() => super.noSuchMethod( + Invocation.method(#applySystemConfiguration, []), + returnValueForMissingStub: null, + ); + + @override + void emit(_i2.SystemConfigurationState? state) => super.noSuchMethod( + Invocation.method(#emit, [state]), + returnValueForMissingStub: null, + ); + + @override + void onChange(_i14.Change<_i2.SystemConfigurationState>? change) => + super.noSuchMethod( + Invocation.method(#onChange, [change]), + returnValueForMissingStub: null, + ); + + @override + void addError(Object? error, [StackTrace? stackTrace]) => super.noSuchMethod( + Invocation.method(#addError, [error, stackTrace]), + returnValueForMissingStub: null, + ); + + @override + void onError(Object? error, StackTrace? stackTrace) => super.noSuchMethod( + Invocation.method(#onError, [error, stackTrace]), + returnValueForMissingStub: null, + ); + + @override + _i12.Future close() => + (super.noSuchMethod( + Invocation.method(#close, []), + returnValue: _i12.Future.value(), + returnValueForMissingStub: _i12.Future.value(), + ) + as _i12.Future); + + @override + void log(String? message) => super.noSuchMethod( + Invocation.method(#log, [message]), + returnValueForMissingStub: null, + ); + + @override + void logException({dynamic exception, StackTrace? stackTrace}) => + super.noSuchMethod( + Invocation.method(#logException, [], { + #exception: exception, + #stackTrace: stackTrace, + }), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [AudioConfigurationCubit]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockAudioConfigurationCubit extends _i1.Mock + implements _i15.AudioConfigurationCubit { + MockAudioConfigurationCubit() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.AudioConfigurationState get state => + (super.noSuchMethod( + Invocation.getter(#state), + returnValue: _FakeAudioConfigurationState_1( + this, + Invocation.getter(#state), + ), + ) + as _i3.AudioConfigurationState); + + @override + _i12.Stream<_i3.AudioConfigurationState> get stream => + (super.noSuchMethod( + Invocation.getter(#stream), + returnValue: _i12.Stream<_i3.AudioConfigurationState>.empty(), + ) + as _i12.Stream<_i3.AudioConfigurationState>); + + @override + bool get isClosed => + (super.noSuchMethod(Invocation.getter(#isClosed), returnValue: false) + as bool); + + @override + _i12.Future fetchConfiguration() => + (super.noSuchMethod( + Invocation.method(#fetchConfiguration, []), + returnValue: _i12.Future.value(), + ) + as _i12.Future); + + @override + void setVolume(int? newVolume) => super.noSuchMethod( + Invocation.method(#setVolume, [newVolume]), + returnValueForMissingStub: null, + ); + + @override + void setState(bool? isEnabled) => super.noSuchMethod( + Invocation.method(#setState, [isEnabled]), + returnValueForMissingStub: null, + ); + + @override + void emit(_i3.AudioConfigurationState? state) => super.noSuchMethod( + Invocation.method(#emit, [state]), + returnValueForMissingStub: null, + ); + + @override + void onChange(_i14.Change<_i3.AudioConfigurationState>? change) => + super.noSuchMethod( + Invocation.method(#onChange, [change]), + returnValueForMissingStub: null, + ); + + @override + void addError(Object? error, [StackTrace? stackTrace]) => super.noSuchMethod( + Invocation.method(#addError, [error, stackTrace]), + returnValueForMissingStub: null, + ); + + @override + void onError(Object? error, StackTrace? stackTrace) => super.noSuchMethod( + Invocation.method(#onError, [error, stackTrace]), + returnValueForMissingStub: null, + ); + + @override + _i12.Future close() => + (super.noSuchMethod( + Invocation.method(#close, []), + returnValue: _i12.Future.value(), + returnValueForMissingStub: _i12.Future.value(), + ) + as _i12.Future); + + @override + void log(String? message) => super.noSuchMethod( + Invocation.method(#log, [message]), + returnValueForMissingStub: null, + ); + + @override + void logException({dynamic exception, StackTrace? stackTrace}) => + super.noSuchMethod( + Invocation.method(#logException, [], { + #exception: exception, + #stackTrace: stackTrace, + }), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [DisplayConfigurationCubit]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockDisplayConfigurationCubit extends _i1.Mock + implements _i16.DisplayConfigurationCubit { + MockDisplayConfigurationCubit() { + _i1.throwOnMissingStub(this); + } + + @override + _i4.DisplayConfigurationState get state => + (super.noSuchMethod( + Invocation.getter(#state), + returnValue: _FakeDisplayConfigurationState_2( + this, + Invocation.getter(#state), + ), + ) + as _i4.DisplayConfigurationState); + + @override + _i12.Stream<_i4.DisplayConfigurationState> get stream => + (super.noSuchMethod( + Invocation.getter(#stream), + returnValue: _i12.Stream<_i4.DisplayConfigurationState>.empty(), + ) + as _i12.Stream<_i4.DisplayConfigurationState>); + + @override + bool get isClosed => + (super.noSuchMethod(Invocation.getter(#isClosed), returnValue: false) + as bool); + + @override + void fetchConfiguration() => super.noSuchMethod( + Invocation.method(#fetchConfiguration, []), + returnValueForMissingStub: null, + ); + + @override + void setResponsiveness(bool? newSetting) => super.noSuchMethod( + Invocation.method(#setResponsiveness, [newSetting]), + returnValueForMissingStub: null, + ); + + @override + void setResolution(_i17.DisplayResolutionModePreset? newPreset) => + super.noSuchMethod( + Invocation.method(#setResolution, [newPreset]), + returnValueForMissingStub: null, + ); + + @override + void setRenderer(_i17.DisplayRendererType? newType) => super.noSuchMethod( + Invocation.method(#setRenderer, [newType]), + returnValueForMissingStub: null, + ); + + @override + void setQuality(_i17.DisplayQualityPreset? newQuality) => super.noSuchMethod( + Invocation.method(#setQuality, [newQuality]), + returnValueForMissingStub: null, + ); + + @override + void setRefreshRate(_i17.DisplayRefreshRatePreset? newRefreshRate) => + super.noSuchMethod( + Invocation.method(#setRefreshRate, [newRefreshRate]), + returnValueForMissingStub: null, + ); + + @override + void emit(_i4.DisplayConfigurationState? state) => super.noSuchMethod( + Invocation.method(#emit, [state]), + returnValueForMissingStub: null, + ); + + @override + void onChange(_i14.Change<_i4.DisplayConfigurationState>? change) => + super.noSuchMethod( + Invocation.method(#onChange, [change]), + returnValueForMissingStub: null, + ); + + @override + void addError(Object? error, [StackTrace? stackTrace]) => super.noSuchMethod( + Invocation.method(#addError, [error, stackTrace]), + returnValueForMissingStub: null, + ); + + @override + void onError(Object? error, StackTrace? stackTrace) => super.noSuchMethod( + Invocation.method(#onError, [error, stackTrace]), + returnValueForMissingStub: null, + ); + + @override + _i12.Future close() => + (super.noSuchMethod( + Invocation.method(#close, []), + returnValue: _i12.Future.value(), + returnValueForMissingStub: _i12.Future.value(), + ) + as _i12.Future); + + @override + void log(String? message) => super.noSuchMethod( + Invocation.method(#log, [message]), + returnValueForMissingStub: null, + ); + + @override + void logException({dynamic exception, StackTrace? stackTrace}) => + super.noSuchMethod( + Invocation.method(#logException, [], { + #exception: exception, + #stackTrace: stackTrace, + }), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [RearDisplayConfigurationCubit]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockRearDisplayConfigurationCubit extends _i1.Mock + implements _i18.RearDisplayConfigurationCubit { + MockRearDisplayConfigurationCubit() { + _i1.throwOnMissingStub(this); + } + + @override + _i5.RearDisplayConfigurationState get state => + (super.noSuchMethod( + Invocation.getter(#state), + returnValue: _FakeRearDisplayConfigurationState_3( + this, + Invocation.getter(#state), + ), + ) + as _i5.RearDisplayConfigurationState); + + @override + _i12.Stream<_i5.RearDisplayConfigurationState> get stream => + (super.noSuchMethod( + Invocation.getter(#stream), + returnValue: _i12.Stream<_i5.RearDisplayConfigurationState>.empty(), + ) + as _i12.Stream<_i5.RearDisplayConfigurationState>); + + @override + bool get isClosed => + (super.noSuchMethod(Invocation.getter(#isClosed), returnValue: false) + as bool); + + @override + void fetchConfiguration() => super.noSuchMethod( + Invocation.method(#fetchConfiguration, []), + returnValueForMissingStub: null, + ); + + @override + void setRearDisplayState(bool? newSetting) => super.noSuchMethod( + Invocation.method(#setRearDisplayState, [newSetting]), + returnValueForMissingStub: null, + ); + + @override + void setRearDisplayPrioritization(bool? newSetting) => super.noSuchMethod( + Invocation.method(#setRearDisplayPrioritization, [newSetting]), + returnValueForMissingStub: null, + ); + + @override + void setDisplayType({required bool? isCurrentDisplayPrimary}) => + super.noSuchMethod( + Invocation.method(#setDisplayType, [], { + #isCurrentDisplayPrimary: isCurrentDisplayPrimary, + }), + returnValueForMissingStub: null, + ); + + @override + void emit(_i5.RearDisplayConfigurationState? state) => super.noSuchMethod( + Invocation.method(#emit, [state]), + returnValueForMissingStub: null, + ); + + @override + void onChange(_i14.Change<_i5.RearDisplayConfigurationState>? change) => + super.noSuchMethod( + Invocation.method(#onChange, [change]), + returnValueForMissingStub: null, + ); + + @override + void addError(Object? error, [StackTrace? stackTrace]) => super.noSuchMethod( + Invocation.method(#addError, [error, stackTrace]), + returnValueForMissingStub: null, + ); + + @override + void onError(Object? error, StackTrace? stackTrace) => super.noSuchMethod( + Invocation.method(#onError, [error, stackTrace]), + returnValueForMissingStub: null, + ); + + @override + _i12.Future close() => + (super.noSuchMethod( + Invocation.method(#close, []), + returnValue: _i12.Future.value(), + returnValueForMissingStub: _i12.Future.value(), + ) + as _i12.Future); + + @override + void log(String? message) => super.noSuchMethod( + Invocation.method(#log, [message]), + returnValueForMissingStub: null, + ); + + @override + void logException({dynamic exception, StackTrace? stackTrace}) => + super.noSuchMethod( + Invocation.method(#logException, [], { + #exception: exception, + #stackTrace: stackTrace, + }), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [GPSConfigurationCubit]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockGPSConfigurationCubit extends _i1.Mock + implements _i19.GPSConfigurationCubit { + MockGPSConfigurationCubit() { + _i1.throwOnMissingStub(this); + } + + @override + _i6.GPSConfigurationState get state => + (super.noSuchMethod( + Invocation.getter(#state), + returnValue: _FakeGPSConfigurationState_4( + this, + Invocation.getter(#state), + ), + ) + as _i6.GPSConfigurationState); + + @override + _i12.Stream<_i6.GPSConfigurationState> get stream => + (super.noSuchMethod( + Invocation.getter(#stream), + returnValue: _i12.Stream<_i6.GPSConfigurationState>.empty(), + ) + as _i12.Stream<_i6.GPSConfigurationState>); + + @override + bool get isClosed => + (super.noSuchMethod(Invocation.getter(#isClosed), returnValue: false) + as bool); + + @override + _i12.Future fetchConfiguration() => + (super.noSuchMethod( + Invocation.method(#fetchConfiguration, []), + returnValue: _i12.Future.value(), + ) + as _i12.Future); + + @override + void setState(bool? newState) => super.noSuchMethod( + Invocation.method(#setState, [newState]), + returnValueForMissingStub: null, + ); + + @override + void emit(_i6.GPSConfigurationState? state) => super.noSuchMethod( + Invocation.method(#emit, [state]), + returnValueForMissingStub: null, + ); + + @override + void onChange(_i14.Change<_i6.GPSConfigurationState>? change) => + super.noSuchMethod( + Invocation.method(#onChange, [change]), + returnValueForMissingStub: null, + ); + + @override + void addError(Object? error, [StackTrace? stackTrace]) => super.noSuchMethod( + Invocation.method(#addError, [error, stackTrace]), + returnValueForMissingStub: null, + ); + + @override + void onError(Object? error, StackTrace? stackTrace) => super.noSuchMethod( + Invocation.method(#onError, [error, stackTrace]), + returnValueForMissingStub: null, + ); + + @override + _i12.Future close() => + (super.noSuchMethod( + Invocation.method(#close, []), + returnValue: _i12.Future.value(), + returnValueForMissingStub: _i12.Future.value(), + ) + as _i12.Future); + + @override + void log(String? message) => super.noSuchMethod( + Invocation.method(#log, [message]), + returnValueForMissingStub: null, + ); + + @override + void logException({dynamic exception, StackTrace? stackTrace}) => + super.noSuchMethod( + Invocation.method(#logException, [], { + #exception: exception, + #stackTrace: stackTrace, + }), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [DeviceInfoCubit]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockDeviceInfoCubit extends _i1.Mock implements _i20.DeviceInfoCubit { + MockDeviceInfoCubit() { + _i1.throwOnMissingStub(this); + } + + @override + _i7.DeviceInfoState get state => + (super.noSuchMethod( + Invocation.getter(#state), + returnValue: _FakeDeviceInfoState_5( + this, + Invocation.getter(#state), + ), + ) + as _i7.DeviceInfoState); + + @override + _i12.Stream<_i7.DeviceInfoState> get stream => + (super.noSuchMethod( + Invocation.getter(#stream), + returnValue: _i12.Stream<_i7.DeviceInfoState>.empty(), + ) + as _i12.Stream<_i7.DeviceInfoState>); + + @override + bool get isClosed => + (super.noSuchMethod(Invocation.getter(#isClosed), returnValue: false) + as bool); + + @override + void fetchConfiguration() => super.noSuchMethod( + Invocation.method(#fetchConfiguration, []), + returnValueForMissingStub: null, + ); + + @override + void emit(_i7.DeviceInfoState? state) => super.noSuchMethod( + Invocation.method(#emit, [state]), + returnValueForMissingStub: null, + ); + + @override + void onChange(_i14.Change<_i7.DeviceInfoState>? change) => super.noSuchMethod( + Invocation.method(#onChange, [change]), + returnValueForMissingStub: null, + ); + + @override + void addError(Object? error, [StackTrace? stackTrace]) => super.noSuchMethod( + Invocation.method(#addError, [error, stackTrace]), + returnValueForMissingStub: null, + ); + + @override + void onError(Object? error, StackTrace? stackTrace) => super.noSuchMethod( + Invocation.method(#onError, [error, stackTrace]), + returnValueForMissingStub: null, + ); + + @override + _i12.Future close() => + (super.noSuchMethod( + Invocation.method(#close, []), + returnValue: _i12.Future.value(), + returnValueForMissingStub: _i12.Future.value(), + ) + as _i12.Future); + + @override + void log(String? message) => super.noSuchMethod( + Invocation.method(#log, [message]), + returnValueForMissingStub: null, + ); + + @override + void logException({dynamic exception, StackTrace? stackTrace}) => + super.noSuchMethod( + Invocation.method(#logException, [], { + #exception: exception, + #stackTrace: stackTrace, + }), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [ConnectivityCheckCubit]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockConnectivityCheckCubit extends _i1.Mock + implements _i21.ConnectivityCheckCubit { + MockConnectivityCheckCubit() { + _i1.throwOnMissingStub(this); + } + + @override + set onReloadOverride(void Function()? _onReloadOverride) => + super.noSuchMethod( + Invocation.setter(#onReloadOverride, _onReloadOverride), + returnValueForMissingStub: null, + ); + + @override + _i22.ConnectivityState get state => + (super.noSuchMethod( + Invocation.getter(#state), + returnValue: _i22.ConnectivityState.backendAccessible, + ) + as _i22.ConnectivityState); + + @override + _i12.Stream<_i22.ConnectivityState> get stream => + (super.noSuchMethod( + Invocation.getter(#stream), + returnValue: _i12.Stream<_i22.ConnectivityState>.empty(), + ) + as _i12.Stream<_i22.ConnectivityState>); + + @override + bool get isClosed => + (super.noSuchMethod(Invocation.getter(#isClosed), returnValue: false) + as bool); + + @override + _i12.Future checkConnectivity() => + (super.noSuchMethod( + Invocation.method(#checkConnectivity, []), + returnValue: _i12.Future.value(), + returnValueForMissingStub: _i12.Future.value(), + ) + as _i12.Future); + + @override + _i12.Future close() => + (super.noSuchMethod( + Invocation.method(#close, []), + returnValue: _i12.Future.value(), + returnValueForMissingStub: _i12.Future.value(), + ) + as _i12.Future); + + @override + void emit(_i22.ConnectivityState? state) => super.noSuchMethod( + Invocation.method(#emit, [state]), + returnValueForMissingStub: null, + ); + + @override + void onChange(_i14.Change<_i22.ConnectivityState>? change) => + super.noSuchMethod( + Invocation.method(#onChange, [change]), + returnValueForMissingStub: null, + ); + + @override + void addError(Object? error, [StackTrace? stackTrace]) => super.noSuchMethod( + Invocation.method(#addError, [error, stackTrace]), + returnValueForMissingStub: null, + ); + + @override + void onError(Object? error, StackTrace? stackTrace) => super.noSuchMethod( + Invocation.method(#onError, [error, stackTrace]), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [DisplayCubit]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockDisplayCubit extends _i1.Mock implements _i23.DisplayCubit { + MockDisplayCubit() { + _i1.throwOnMissingStub(this); + } + + @override + _i8.DisplayState get state => + (super.noSuchMethod( + Invocation.getter(#state), + returnValue: _FakeDisplayState_6(this, Invocation.getter(#state)), + ) + as _i8.DisplayState); + + @override + _i12.Stream<_i8.DisplayState> get stream => + (super.noSuchMethod( + Invocation.getter(#stream), + returnValue: _i12.Stream<_i8.DisplayState>.empty(), + ) + as _i12.Stream<_i8.DisplayState>); + + @override + bool get isClosed => + (super.noSuchMethod(Invocation.getter(#isClosed), returnValue: false) + as bool); + + @override + _i12.Future close() => + (super.noSuchMethod( + Invocation.method(#close, []), + returnValue: _i12.Future.value(), + returnValueForMissingStub: _i12.Future.value(), + ) + as _i12.Future); + + @override + _i12.Future resizeDisplay({required _i24.Size? viewSize}) => + (super.noSuchMethod( + Invocation.method(#resizeDisplay, [], {#viewSize: viewSize}), + returnValue: _i12.Future.value(), + returnValueForMissingStub: _i12.Future.value(), + ) + as _i12.Future); + + @override + _i12.Future onWindowSizeChanged(_i24.Size? updatedSize) => + (super.noSuchMethod( + Invocation.method(#onWindowSizeChanged, [updatedSize]), + returnValue: _i12.Future.value(), + returnValueForMissingStub: _i12.Future.value(), + ) + as _i12.Future); + + @override + _i12.Future onDisplayTypeSelectionFinished({ + required bool? isPrimaryDisplay, + }) => + (super.noSuchMethod( + Invocation.method(#onDisplayTypeSelectionFinished, [], { + #isPrimaryDisplay: isPrimaryDisplay, + }), + returnValue: _i12.Future.value(), + returnValueForMissingStub: _i12.Future.value(), + ) + as _i12.Future); + + @override + void emit(_i8.DisplayState? state) => super.noSuchMethod( + Invocation.method(#emit, [state]), + returnValueForMissingStub: null, + ); + + @override + void onChange(_i14.Change<_i8.DisplayState>? change) => super.noSuchMethod( + Invocation.method(#onChange, [change]), + returnValueForMissingStub: null, + ); + + @override + void addError(Object? error, [StackTrace? stackTrace]) => super.noSuchMethod( + Invocation.method(#addError, [error, stackTrace]), + returnValueForMissingStub: null, + ); + + @override + void onError(Object? error, StackTrace? stackTrace) => super.noSuchMethod( + Invocation.method(#onError, [error, stackTrace]), + returnValueForMissingStub: null, + ); + + @override + void log(String? message) => super.noSuchMethod( + Invocation.method(#log, [message]), + returnValueForMissingStub: null, + ); + + @override + void logException({dynamic exception, StackTrace? stackTrace}) => + super.noSuchMethod( + Invocation.method(#logException, [], { + #exception: exception, + #stackTrace: stackTrace, + }), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [OTAUpdateCubit]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockOTAUpdateCubit extends _i1.Mock implements _i25.OTAUpdateCubit { + MockOTAUpdateCubit() { + _i1.throwOnMissingStub(this); + } + + @override + _i9.OTAUpdateState get state => + (super.noSuchMethod( + Invocation.getter(#state), + returnValue: _FakeOTAUpdateState_7(this, Invocation.getter(#state)), + ) + as _i9.OTAUpdateState); + + @override + _i12.Stream<_i9.OTAUpdateState> get stream => + (super.noSuchMethod( + Invocation.getter(#stream), + returnValue: _i12.Stream<_i9.OTAUpdateState>.empty(), + ) + as _i12.Stream<_i9.OTAUpdateState>); + + @override + bool get isClosed => + (super.noSuchMethod(Invocation.getter(#isClosed), returnValue: false) + as bool); + + @override + _i12.Future checkForUpdates() => + (super.noSuchMethod( + Invocation.method(#checkForUpdates, []), + returnValue: _i12.Future.value(), + returnValueForMissingStub: _i12.Future.value(), + ) + as _i12.Future); + + @override + void launchUpdater() => super.noSuchMethod( + Invocation.method(#launchUpdater, []), + returnValueForMissingStub: null, + ); + + @override + void emit(_i9.OTAUpdateState? state) => super.noSuchMethod( + Invocation.method(#emit, [state]), + returnValueForMissingStub: null, + ); + + @override + void onChange(_i14.Change<_i9.OTAUpdateState>? change) => super.noSuchMethod( + Invocation.method(#onChange, [change]), + returnValueForMissingStub: null, + ); + + @override + void addError(Object? error, [StackTrace? stackTrace]) => super.noSuchMethod( + Invocation.method(#addError, [error, stackTrace]), + returnValueForMissingStub: null, + ); + + @override + void onError(Object? error, StackTrace? stackTrace) => super.noSuchMethod( + Invocation.method(#onError, [error, stackTrace]), + returnValueForMissingStub: null, + ); + + @override + _i12.Future close() => + (super.noSuchMethod( + Invocation.method(#close, []), + returnValue: _i12.Future.value(), + returnValueForMissingStub: _i12.Future.value(), + ) + as _i12.Future); + + @override + void log(String? message) => super.noSuchMethod( + Invocation.method(#log, [message]), + returnValueForMissingStub: null, + ); + + @override + void logException({dynamic exception, StackTrace? stackTrace}) => + super.noSuchMethod( + Invocation.method(#logException, [], { + #exception: exception, + #stackTrace: stackTrace, + }), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [TouchscreenCubit]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockTouchscreenCubit extends _i1.Mock implements _i26.TouchscreenCubit { + MockTouchscreenCubit() { + _i1.throwOnMissingStub(this); + } + + @override + List<_i27.VirtualTouchscreenSlotState> get slotsState => + (super.noSuchMethod( + Invocation.getter(#slotsState), + returnValue: <_i27.VirtualTouchscreenSlotState>[], + ) + as List<_i27.VirtualTouchscreenSlotState>); + + @override + bool get state => + (super.noSuchMethod(Invocation.getter(#state), returnValue: false) + as bool); + + @override + _i12.Stream get stream => + (super.noSuchMethod( + Invocation.getter(#stream), + returnValue: _i12.Stream.empty(), + ) + as _i12.Stream); + + @override + bool get isClosed => + (super.noSuchMethod(Invocation.getter(#isClosed), returnValue: false) + as bool); + + @override + _i12.Future close() => + (super.noSuchMethod( + Invocation.method(#close, []), + returnValue: _i12.Future.value(), + returnValueForMissingStub: _i12.Future.value(), + ) + as _i12.Future); + + @override + void handlePointerDownEvent( + _i28.PointerDownEvent? event, + _i28.BoxConstraints? constraints, + _i24.Size? touchscreenSize, + ) => super.noSuchMethod( + Invocation.method(#handlePointerDownEvent, [ + event, + constraints, + touchscreenSize, + ]), + returnValueForMissingStub: null, + ); + + @override + void handlePointerMoveEvent( + _i28.PointerMoveEvent? event, + _i28.BoxConstraints? constraints, + _i24.Size? touchscreenSize, + ) => super.noSuchMethod( + Invocation.method(#handlePointerMoveEvent, [ + event, + constraints, + touchscreenSize, + ]), + returnValueForMissingStub: null, + ); + + @override + void handlePointerUpEvent( + _i28.PointerEvent? event, + _i28.BoxConstraints? constraints, + ) => super.noSuchMethod( + Invocation.method(#handlePointerUpEvent, [event, constraints]), + returnValueForMissingStub: null, + ); + + @override + void resetTouchScreen() => super.noSuchMethod( + Invocation.method(#resetTouchScreen, []), + returnValueForMissingStub: null, + ); + + @override + void sendCommands({ + required List<_i29.VirtualTouchScreenCommand>? commands, + }) => super.noSuchMethod( + Invocation.method(#sendCommands, [], {#commands: commands}), + returnValueForMissingStub: null, + ); + + @override + void sendCommand(_i29.VirtualTouchScreenCommand? command) => + super.noSuchMethod( + Invocation.method(#sendCommand, [command]), + returnValueForMissingStub: null, + ); + + @override + void emit(bool? state) => super.noSuchMethod( + Invocation.method(#emit, [state]), + returnValueForMissingStub: null, + ); + + @override + void onChange(_i14.Change? change) => super.noSuchMethod( + Invocation.method(#onChange, [change]), + returnValueForMissingStub: null, + ); + + @override + void addError(Object? error, [StackTrace? stackTrace]) => super.noSuchMethod( + Invocation.method(#addError, [error, stackTrace]), + returnValueForMissingStub: null, + ); + + @override + void onError(Object? error, StackTrace? stackTrace) => super.noSuchMethod( + Invocation.method(#onError, [error, stackTrace]), + returnValueForMissingStub: null, + ); + + @override + void log(String? message) => super.noSuchMethod( + Invocation.method(#log, [message]), + returnValueForMissingStub: null, + ); + + @override + void logException({dynamic exception, StackTrace? stackTrace}) => + super.noSuchMethod( + Invocation.method(#logException, [], { + #exception: exception, + #stackTrace: stackTrace, + }), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [AudioService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockAudioService extends _i1.Mock implements _i30.AudioService { + MockAudioService() { + _i1.throwOnMissingStub(this); + } + + @override + void setupAudioConfig(String? configJson) => super.noSuchMethod( + Invocation.method(#setupAudioConfig, [configJson]), + returnValueForMissingStub: null, + ); + + @override + void startAudioFromGesture() => super.noSuchMethod( + Invocation.method(#startAudioFromGesture, []), + returnValueForMissingStub: null, + ); + + @override + void stopAudio() => super.noSuchMethod( + Invocation.method(#stopAudio, []), + returnValueForMissingStub: null, + ); + + @override + String getAudioState() => + (super.noSuchMethod( + Invocation.method(#getAudioState, []), + returnValue: _i31.dummyValue( + this, + Invocation.method(#getAudioState, []), + ), + ) + as String); + + @override + _i24.VoidCallback addAudioStateListener(void Function(String)? onState) => + (super.noSuchMethod( + Invocation.method(#addAudioStateListener, [onState]), + returnValue: () {}, + ) + as _i24.VoidCallback); +} + +/// A class which mocks [ReleaseNotesCubit]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockReleaseNotesCubit extends _i1.Mock implements _i32.ReleaseNotesCubit { + MockReleaseNotesCubit() { + _i1.throwOnMissingStub(this); + } + + @override + _i10.ReleaseNotesState get state => + (super.noSuchMethod( + Invocation.getter(#state), + returnValue: _FakeReleaseNotesState_8( + this, + Invocation.getter(#state), + ), + ) + as _i10.ReleaseNotesState); + + @override + _i12.Stream<_i10.ReleaseNotesState> get stream => + (super.noSuchMethod( + Invocation.getter(#stream), + returnValue: _i12.Stream<_i10.ReleaseNotesState>.empty(), + ) + as _i12.Stream<_i10.ReleaseNotesState>); + + @override + bool get isClosed => + (super.noSuchMethod(Invocation.getter(#isClosed), returnValue: false) + as bool); + + @override + void loadReleaseNotes() => super.noSuchMethod( + Invocation.method(#loadReleaseNotes, []), + returnValueForMissingStub: null, + ); + + @override + void updateSelection({ + required _i33.Version? version, + required _i34.ChangelogItem? changelogItem, + }) => super.noSuchMethod( + Invocation.method(#updateSelection, [], { + #version: version, + #changelogItem: changelogItem, + }), + returnValueForMissingStub: null, + ); + + @override + void emit(_i10.ReleaseNotesState? state) => super.noSuchMethod( + Invocation.method(#emit, [state]), + returnValueForMissingStub: null, + ); + + @override + void onChange(_i14.Change<_i10.ReleaseNotesState>? change) => + super.noSuchMethod( + Invocation.method(#onChange, [change]), + returnValueForMissingStub: null, + ); + + @override + void addError(Object? error, [StackTrace? stackTrace]) => super.noSuchMethod( + Invocation.method(#addError, [error, stackTrace]), + returnValueForMissingStub: null, + ); + + @override + void onError(Object? error, StackTrace? stackTrace) => super.noSuchMethod( + Invocation.method(#onError, [error, stackTrace]), + returnValueForMissingStub: null, + ); + + @override + _i12.Future close() => + (super.noSuchMethod( + Invocation.method(#close, []), + returnValue: _i12.Future.value(), + returnValueForMissingStub: _i12.Future.value(), + ) + as _i12.Future); +} diff --git a/test/helpers/mock_services.dart b/test/helpers/mock_services.dart new file mode 100644 index 0000000..2dd7e5f --- /dev/null +++ b/test/helpers/mock_services.dart @@ -0,0 +1,25 @@ +import 'package:mockito/annotations.dart'; +import 'package:dio/dio.dart'; +import 'package:tesla_android/common/service/audio_service.dart'; +import 'package:tesla_android/common/service/window_service.dart'; +import 'package:tesla_android/feature/touchscreen/service/message_sender.dart'; +import 'package:tesla_android/common/service/dialog_service.dart'; + +import 'package:tesla_android/common/network/device_info_service.dart'; +import 'package:tesla_android/common/network/github_service.dart'; + +import 'package:tesla_android/common/navigation/ta_page_factory.dart'; +import 'package:flavor/flavor.dart'; + +@GenerateMocks([ + AudioService, + WindowService, + MessageSender, + GitHubService, + DeviceInfoService, + TAPageFactory, + Flavor, + Dio, + DialogService, +]) +void main() {} diff --git a/test/helpers/mock_services.mocks.dart b/test/helpers/mock_services.mocks.dart new file mode 100644 index 0000000..a85e307 --- /dev/null +++ b/test/helpers/mock_services.mocks.dart @@ -0,0 +1,1104 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in tesla_android/test/helpers/mock_services.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i13; +import 'dart:ui' as _i9; + +import 'package:dio/dio.dart' as _i6; +import 'package:flavor/flavor.dart' as _i17; +import 'package:flutter/foundation.dart' as _i5; +import 'package:flutter/material.dart' as _i4; +import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i8; +import 'package:tesla_android/common/navigation/ta_page.dart' as _i16; +import 'package:tesla_android/common/navigation/ta_page_factory.dart' as _i15; +import 'package:tesla_android/common/network/device_info_service.dart' as _i14; +import 'package:tesla_android/common/network/github_service.dart' as _i12; +import 'package:tesla_android/common/service/audio_service.dart' as _i7; +import 'package:tesla_android/common/service/dialog_service.dart' as _i18; +import 'package:tesla_android/common/service/window_service.dart' as _i10; +import 'package:tesla_android/feature/home/model/github_release.dart' as _i2; +import 'package:tesla_android/feature/settings/model/device_info.dart' as _i3; +import 'package:tesla_android/feature/touchscreen/service/message_sender.dart' + as _i11; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeGitHubRelease_0 extends _i1.SmartFake implements _i2.GitHubRelease { + _FakeGitHubRelease_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeDeviceInfo_1 extends _i1.SmartFake implements _i3.DeviceInfo { + _FakeDeviceInfo_1(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeWidget_2 extends _i1.SmartFake implements _i4.Widget { + _FakeWidget_2(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); + + @override + String toString({_i5.DiagnosticLevel? minLevel = _i5.DiagnosticLevel.info}) => + super.toString(); +} + +class _FakeBaseOptions_3 extends _i1.SmartFake implements _i6.BaseOptions { + _FakeBaseOptions_3(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeHttpClientAdapter_4 extends _i1.SmartFake + implements _i6.HttpClientAdapter { + _FakeHttpClientAdapter_4(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeTransformer_5 extends _i1.SmartFake implements _i6.Transformer { + _FakeTransformer_5(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeInterceptors_6 extends _i1.SmartFake implements _i6.Interceptors { + _FakeInterceptors_6(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeResponse_7 extends _i1.SmartFake implements _i6.Response { + _FakeResponse_7(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeDio_8 extends _i1.SmartFake implements _i6.Dio { + _FakeDio_8(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +/// A class which mocks [AudioService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockAudioService extends _i1.Mock implements _i7.AudioService { + MockAudioService() { + _i1.throwOnMissingStub(this); + } + + @override + void setupAudioConfig(String? configJson) => super.noSuchMethod( + Invocation.method(#setupAudioConfig, [configJson]), + returnValueForMissingStub: null, + ); + + @override + void startAudioFromGesture() => super.noSuchMethod( + Invocation.method(#startAudioFromGesture, []), + returnValueForMissingStub: null, + ); + + @override + void stopAudio() => super.noSuchMethod( + Invocation.method(#stopAudio, []), + returnValueForMissingStub: null, + ); + + @override + String getAudioState() => + (super.noSuchMethod( + Invocation.method(#getAudioState, []), + returnValue: _i8.dummyValue( + this, + Invocation.method(#getAudioState, []), + ), + ) + as String); + + @override + _i9.VoidCallback addAudioStateListener(void Function(String)? onState) => + (super.noSuchMethod( + Invocation.method(#addAudioStateListener, [onState]), + returnValue: () {}, + ) + as _i9.VoidCallback); +} + +/// A class which mocks [WindowService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockWindowService extends _i1.Mock implements _i10.WindowService { + MockWindowService() { + _i1.throwOnMissingStub(this); + } + + @override + void reload() => super.noSuchMethod( + Invocation.method(#reload, []), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [MessageSender]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockMessageSender extends _i1.Mock implements _i11.MessageSender { + MockMessageSender() { + _i1.throwOnMissingStub(this); + } + + @override + void postMessage(String? message, String? targetOrigin) => super.noSuchMethod( + Invocation.method(#postMessage, [message, targetOrigin]), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [GitHubService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockGitHubService extends _i1.Mock implements _i12.GitHubService { + MockGitHubService() { + _i1.throwOnMissingStub(this); + } + + @override + _i13.Future<_i2.GitHubRelease> getLatestRelease() => + (super.noSuchMethod( + Invocation.method(#getLatestRelease, []), + returnValue: _i13.Future<_i2.GitHubRelease>.value( + _FakeGitHubRelease_0( + this, + Invocation.method(#getLatestRelease, []), + ), + ), + ) + as _i13.Future<_i2.GitHubRelease>); +} + +/// A class which mocks [DeviceInfoService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockDeviceInfoService extends _i1.Mock implements _i14.DeviceInfoService { + MockDeviceInfoService() { + _i1.throwOnMissingStub(this); + } + + @override + _i13.Future<_i3.DeviceInfo> getDeviceInfo() => + (super.noSuchMethod( + Invocation.method(#getDeviceInfo, []), + returnValue: _i13.Future<_i3.DeviceInfo>.value( + _FakeDeviceInfo_1(this, Invocation.method(#getDeviceInfo, [])), + ), + ) + as _i13.Future<_i3.DeviceInfo>); + + @override + _i13.Future openUpdater() => + (super.noSuchMethod( + Invocation.method(#openUpdater, []), + returnValue: _i13.Future.value(), + ) + as _i13.Future); +} + +/// A class which mocks [TAPageFactory]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockTAPageFactory extends _i1.Mock implements _i15.TAPageFactory { + MockTAPageFactory() { + _i1.throwOnMissingStub(this); + } + + @override + String get initialRoute => + (super.noSuchMethod( + Invocation.getter(#initialRoute), + returnValue: _i8.dummyValue( + this, + Invocation.getter(#initialRoute), + ), + ) + as String); + + @override + set initialRoute(String? _initialRoute) => super.noSuchMethod( + Invocation.setter(#initialRoute, _initialRoute), + returnValueForMissingStub: null, + ); + + @override + Map getRoutes() => + (super.noSuchMethod( + Invocation.method(#getRoutes, []), + returnValue: {}, + ) + as Map); + + @override + _i4.Widget Function(_i4.BuildContext) buildPage(_i16.TAPage? page) => + (super.noSuchMethod( + Invocation.method(#buildPage, [page]), + returnValue: (_i4.BuildContext context) => + _FakeWidget_2(this, Invocation.method(#buildPage, [page])), + ) + as _i4.Widget Function(_i4.BuildContext)); +} + +/// A class which mocks [Flavor]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockFlavor extends _i1.Mock implements _i17.Flavor { + MockFlavor() { + _i1.throwOnMissingStub(this); + } + + @override + _i17.Environment get environment => + (super.noSuchMethod( + Invocation.getter(#environment), + returnValue: _i17.Environment.dev, + ) + as _i17.Environment); + + @override + bool get isDevelopment => + (super.noSuchMethod(Invocation.getter(#isDevelopment), returnValue: false) + as bool); + + @override + bool get isAlpha => + (super.noSuchMethod(Invocation.getter(#isAlpha), returnValue: false) + as bool); + + @override + bool get isBeta => + (super.noSuchMethod(Invocation.getter(#isBeta), returnValue: false) + as bool); + + @override + bool get isProduction => + (super.noSuchMethod(Invocation.getter(#isProduction), returnValue: false) + as bool); + + @override + Object? getObject(String? key) => + (super.noSuchMethod(Invocation.method(#getObject, [key])) as Object?); + + @override + String? getString(String? key) => + (super.noSuchMethod(Invocation.method(#getString, [key])) as String?); + + @override + int? getInt(String? key) => + (super.noSuchMethod(Invocation.method(#getInt, [key])) as int?); + + @override + double? getDouble(String? key) => + (super.noSuchMethod(Invocation.method(#getDouble, [key])) as double?); + + @override + bool? getBool(String? key) => + (super.noSuchMethod(Invocation.method(#getBool, [key])) as bool?); +} + +/// A class which mocks [Dio]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockDio extends _i1.Mock implements _i6.Dio { + MockDio() { + _i1.throwOnMissingStub(this); + } + + @override + _i6.BaseOptions get options => + (super.noSuchMethod( + Invocation.getter(#options), + returnValue: _FakeBaseOptions_3(this, Invocation.getter(#options)), + ) + as _i6.BaseOptions); + + @override + _i6.HttpClientAdapter get httpClientAdapter => + (super.noSuchMethod( + Invocation.getter(#httpClientAdapter), + returnValue: _FakeHttpClientAdapter_4( + this, + Invocation.getter(#httpClientAdapter), + ), + ) + as _i6.HttpClientAdapter); + + @override + _i6.Transformer get transformer => + (super.noSuchMethod( + Invocation.getter(#transformer), + returnValue: _FakeTransformer_5( + this, + Invocation.getter(#transformer), + ), + ) + as _i6.Transformer); + + @override + _i6.Interceptors get interceptors => + (super.noSuchMethod( + Invocation.getter(#interceptors), + returnValue: _FakeInterceptors_6( + this, + Invocation.getter(#interceptors), + ), + ) + as _i6.Interceptors); + + @override + set options(_i6.BaseOptions? _options) => super.noSuchMethod( + Invocation.setter(#options, _options), + returnValueForMissingStub: null, + ); + + @override + set httpClientAdapter(_i6.HttpClientAdapter? _httpClientAdapter) => + super.noSuchMethod( + Invocation.setter(#httpClientAdapter, _httpClientAdapter), + returnValueForMissingStub: null, + ); + + @override + set transformer(_i6.Transformer? _transformer) => super.noSuchMethod( + Invocation.setter(#transformer, _transformer), + returnValueForMissingStub: null, + ); + + @override + void close({bool? force = false}) => super.noSuchMethod( + Invocation.method(#close, [], {#force: force}), + returnValueForMissingStub: null, + ); + + @override + _i13.Future<_i6.Response> head( + String? path, { + Object? data, + Map? queryParameters, + _i6.Options? options, + _i6.CancelToken? cancelToken, + }) => + (super.noSuchMethod( + Invocation.method( + #head, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + }, + ), + returnValue: _i13.Future<_i6.Response>.value( + _FakeResponse_7( + this, + Invocation.method( + #head, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + }, + ), + ), + ), + ) + as _i13.Future<_i6.Response>); + + @override + _i13.Future<_i6.Response> headUri( + Uri? uri, { + Object? data, + _i6.Options? options, + _i6.CancelToken? cancelToken, + }) => + (super.noSuchMethod( + Invocation.method( + #headUri, + [uri], + {#data: data, #options: options, #cancelToken: cancelToken}, + ), + returnValue: _i13.Future<_i6.Response>.value( + _FakeResponse_7( + this, + Invocation.method( + #headUri, + [uri], + {#data: data, #options: options, #cancelToken: cancelToken}, + ), + ), + ), + ) + as _i13.Future<_i6.Response>); + + @override + _i13.Future<_i6.Response> get( + String? path, { + Object? data, + Map? queryParameters, + _i6.Options? options, + _i6.CancelToken? cancelToken, + _i6.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #get, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i13.Future<_i6.Response>.value( + _FakeResponse_7( + this, + Invocation.method( + #get, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i13.Future<_i6.Response>); + + @override + _i13.Future<_i6.Response> getUri( + Uri? uri, { + Object? data, + _i6.Options? options, + _i6.CancelToken? cancelToken, + _i6.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #getUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i13.Future<_i6.Response>.value( + _FakeResponse_7( + this, + Invocation.method( + #getUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i13.Future<_i6.Response>); + + @override + _i13.Future<_i6.Response> post( + String? path, { + Object? data, + Map? queryParameters, + _i6.Options? options, + _i6.CancelToken? cancelToken, + _i6.ProgressCallback? onSendProgress, + _i6.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #post, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i13.Future<_i6.Response>.value( + _FakeResponse_7( + this, + Invocation.method( + #post, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i13.Future<_i6.Response>); + + @override + _i13.Future<_i6.Response> postUri( + Uri? uri, { + Object? data, + _i6.Options? options, + _i6.CancelToken? cancelToken, + _i6.ProgressCallback? onSendProgress, + _i6.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #postUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i13.Future<_i6.Response>.value( + _FakeResponse_7( + this, + Invocation.method( + #postUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i13.Future<_i6.Response>); + + @override + _i13.Future<_i6.Response> put( + String? path, { + Object? data, + Map? queryParameters, + _i6.Options? options, + _i6.CancelToken? cancelToken, + _i6.ProgressCallback? onSendProgress, + _i6.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #put, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i13.Future<_i6.Response>.value( + _FakeResponse_7( + this, + Invocation.method( + #put, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i13.Future<_i6.Response>); + + @override + _i13.Future<_i6.Response> putUri( + Uri? uri, { + Object? data, + _i6.Options? options, + _i6.CancelToken? cancelToken, + _i6.ProgressCallback? onSendProgress, + _i6.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #putUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i13.Future<_i6.Response>.value( + _FakeResponse_7( + this, + Invocation.method( + #putUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i13.Future<_i6.Response>); + + @override + _i13.Future<_i6.Response> patch( + String? path, { + Object? data, + Map? queryParameters, + _i6.Options? options, + _i6.CancelToken? cancelToken, + _i6.ProgressCallback? onSendProgress, + _i6.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #patch, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i13.Future<_i6.Response>.value( + _FakeResponse_7( + this, + Invocation.method( + #patch, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i13.Future<_i6.Response>); + + @override + _i13.Future<_i6.Response> patchUri( + Uri? uri, { + Object? data, + _i6.Options? options, + _i6.CancelToken? cancelToken, + _i6.ProgressCallback? onSendProgress, + _i6.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #patchUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i13.Future<_i6.Response>.value( + _FakeResponse_7( + this, + Invocation.method( + #patchUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i13.Future<_i6.Response>); + + @override + _i13.Future<_i6.Response> delete( + String? path, { + Object? data, + Map? queryParameters, + _i6.Options? options, + _i6.CancelToken? cancelToken, + }) => + (super.noSuchMethod( + Invocation.method( + #delete, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + }, + ), + returnValue: _i13.Future<_i6.Response>.value( + _FakeResponse_7( + this, + Invocation.method( + #delete, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + }, + ), + ), + ), + ) + as _i13.Future<_i6.Response>); + + @override + _i13.Future<_i6.Response> deleteUri( + Uri? uri, { + Object? data, + _i6.Options? options, + _i6.CancelToken? cancelToken, + }) => + (super.noSuchMethod( + Invocation.method( + #deleteUri, + [uri], + {#data: data, #options: options, #cancelToken: cancelToken}, + ), + returnValue: _i13.Future<_i6.Response>.value( + _FakeResponse_7( + this, + Invocation.method( + #deleteUri, + [uri], + {#data: data, #options: options, #cancelToken: cancelToken}, + ), + ), + ), + ) + as _i13.Future<_i6.Response>); + + @override + _i13.Future<_i6.Response> download( + String? urlPath, + dynamic savePath, { + _i6.ProgressCallback? onReceiveProgress, + Map? queryParameters, + _i6.CancelToken? cancelToken, + bool? deleteOnError = true, + _i6.FileAccessMode? fileAccessMode = _i6.FileAccessMode.write, + String? lengthHeader = 'content-length', + Object? data, + _i6.Options? options, + }) => + (super.noSuchMethod( + Invocation.method( + #download, + [urlPath, savePath], + { + #onReceiveProgress: onReceiveProgress, + #queryParameters: queryParameters, + #cancelToken: cancelToken, + #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, + #lengthHeader: lengthHeader, + #data: data, + #options: options, + }, + ), + returnValue: _i13.Future<_i6.Response>.value( + _FakeResponse_7( + this, + Invocation.method( + #download, + [urlPath, savePath], + { + #onReceiveProgress: onReceiveProgress, + #queryParameters: queryParameters, + #cancelToken: cancelToken, + #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, + #lengthHeader: lengthHeader, + #data: data, + #options: options, + }, + ), + ), + ), + ) + as _i13.Future<_i6.Response>); + + @override + _i13.Future<_i6.Response> downloadUri( + Uri? uri, + dynamic savePath, { + _i6.ProgressCallback? onReceiveProgress, + _i6.CancelToken? cancelToken, + bool? deleteOnError = true, + _i6.FileAccessMode? fileAccessMode = _i6.FileAccessMode.write, + String? lengthHeader = 'content-length', + Object? data, + _i6.Options? options, + }) => + (super.noSuchMethod( + Invocation.method( + #downloadUri, + [uri, savePath], + { + #onReceiveProgress: onReceiveProgress, + #cancelToken: cancelToken, + #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, + #lengthHeader: lengthHeader, + #data: data, + #options: options, + }, + ), + returnValue: _i13.Future<_i6.Response>.value( + _FakeResponse_7( + this, + Invocation.method( + #downloadUri, + [uri, savePath], + { + #onReceiveProgress: onReceiveProgress, + #cancelToken: cancelToken, + #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, + #lengthHeader: lengthHeader, + #data: data, + #options: options, + }, + ), + ), + ), + ) + as _i13.Future<_i6.Response>); + + @override + _i13.Future<_i6.Response> request( + String? url, { + Object? data, + Map? queryParameters, + _i6.CancelToken? cancelToken, + _i6.Options? options, + _i6.ProgressCallback? onSendProgress, + _i6.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #request, + [url], + { + #data: data, + #queryParameters: queryParameters, + #cancelToken: cancelToken, + #options: options, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i13.Future<_i6.Response>.value( + _FakeResponse_7( + this, + Invocation.method( + #request, + [url], + { + #data: data, + #queryParameters: queryParameters, + #cancelToken: cancelToken, + #options: options, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i13.Future<_i6.Response>); + + @override + _i13.Future<_i6.Response> requestUri( + Uri? uri, { + Object? data, + _i6.CancelToken? cancelToken, + _i6.Options? options, + _i6.ProgressCallback? onSendProgress, + _i6.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #requestUri, + [uri], + { + #data: data, + #cancelToken: cancelToken, + #options: options, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i13.Future<_i6.Response>.value( + _FakeResponse_7( + this, + Invocation.method( + #requestUri, + [uri], + { + #data: data, + #cancelToken: cancelToken, + #options: options, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i13.Future<_i6.Response>); + + @override + _i13.Future<_i6.Response> fetch(_i6.RequestOptions? requestOptions) => + (super.noSuchMethod( + Invocation.method(#fetch, [requestOptions]), + returnValue: _i13.Future<_i6.Response>.value( + _FakeResponse_7( + this, + Invocation.method(#fetch, [requestOptions]), + ), + ), + ) + as _i13.Future<_i6.Response>); + + @override + _i6.Dio clone({ + _i6.BaseOptions? options, + _i6.Interceptors? interceptors, + _i6.HttpClientAdapter? httpClientAdapter, + _i6.Transformer? transformer, + }) => + (super.noSuchMethod( + Invocation.method(#clone, [], { + #options: options, + #interceptors: interceptors, + #httpClientAdapter: httpClientAdapter, + #transformer: transformer, + }), + returnValue: _FakeDio_8( + this, + Invocation.method(#clone, [], { + #options: options, + #interceptors: interceptors, + #httpClientAdapter: httpClientAdapter, + #transformer: transformer, + }), + ), + ) + as _i6.Dio); +} + +/// A class which mocks [DialogService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockDialogService extends _i1.Mock implements _i18.DialogService { + MockDialogService() { + _i1.throwOnMissingStub(this); + } + + @override + void showMaterialBanner({ + required _i4.BuildContext? context, + required _i4.MaterialBanner? banner, + }) => super.noSuchMethod( + Invocation.method(#showMaterialBanner, [], { + #context: context, + #banner: banner, + }), + returnValueForMissingStub: null, + ); + + @override + void clearMaterialBanners({required _i4.BuildContext? context}) => + super.noSuchMethod( + Invocation.method(#clearMaterialBanners, [], {#context: context}), + returnValueForMissingStub: null, + ); + + @override + _i13.Future showDialog({ + required _i4.BuildContext? context, + required _i4.WidgetBuilder? builder, + bool? barrierDismissible = true, + }) => + (super.noSuchMethod( + Invocation.method(#showDialog, [], { + #context: context, + #builder: builder, + #barrierDismissible: barrierDismissible, + }), + returnValue: _i13.Future.value(), + ) + as _i13.Future); +} diff --git a/test/helpers/settings_test_helpers.dart b/test/helpers/settings_test_helpers.dart new file mode 100644 index 0000000..490ffa3 --- /dev/null +++ b/test/helpers/settings_test_helpers.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tesla_android/feature/settings/bloc/audio_configuration_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/device_info_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/display_configuration_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/gps_configuration_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/rear_display_configuration_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/system_configuration_cubit.dart'; + +import 'package:get_it/get_it.dart'; +import 'package:tesla_android/common/navigation/ta_page_factory.dart'; +import 'cubit_builders.dart'; + +/// Test helpers for settings widgets +/// +/// Provides convenient wrapper functions to reduce boilerplate in widget tests. +class SettingsTestHelpers { + /// Creates a testable widget wrapped with MaterialApp and a single BlocProvider + /// + /// Example: + /// ```dart + /// await tester.pumpWidget( + /// SettingsTestHelpers.buildWithSingleProvider( + /// provider: mockDisplayCubit, + /// child: const DisplaySettings(), + /// ), + /// ); + /// ``` + static Widget buildWithSingleProvider< + T extends StateStreamableSource + >({required T provider, required Widget child}) { + return MaterialApp( + home: Scaffold( + body: BlocProvider.value(value: provider, child: child), + ), + ); + } + + /// Creates a testable widget wrapped with MaterialApp and Scaffold + /// + /// Uses provided child directly in a Scaffold + /// + /// Example: + /// ```dart + /// await tester.pumpWidget( + /// SettingsTestHelpers.buildWithProvider( + /// child: BlocProvider.value( + /// value: mockDisplayCubit, + /// child: const DisplaySettings(), + /// ), + /// ), + /// ); + /// ``` + static Widget buildWithProvider({required Widget child}) { + return MaterialApp(home: Scaffold(body: child)); + } + + /// Creates the standard MultiBlocProvider for settings widgets + static MultiBlocProvider _buildSettingsProviders({ + DisplayConfigurationCubit? displayCubit, + RearDisplayConfigurationCubit? rearDisplayCubit, + SystemConfigurationCubit? systemCubit, + AudioConfigurationCubit? audioCubit, + GPSConfigurationCubit? gpsCubit, + DeviceInfoCubit? deviceInfoCubit, + required Widget child, + }) { + return MultiBlocProvider( + providers: [ + BlocProvider.value( + value: displayCubit ?? CubitBuilders.buildDisplayConfigurationCubit(), + ), + BlocProvider.value( + value: + rearDisplayCubit ?? + CubitBuilders.buildRearDisplayConfigurationCubit(), + ), + BlocProvider.value( + value: systemCubit ?? CubitBuilders.buildSystemConfigurationCubit(), + ), + BlocProvider.value( + value: audioCubit ?? CubitBuilders.buildAudioConfigurationCubit(), + ), + BlocProvider.value( + value: gpsCubit ?? CubitBuilders.buildGPSConfigurationCubit(), + ), + BlocProvider.value( + value: deviceInfoCubit ?? CubitBuilders.buildDeviceInfoCubit(), + ), + ], + child: child, + ); + } + + /// Creates a testable settings widget with all standard settings providers + /// + /// Automatically creates default cubits using CubitBuilders. + /// Override specific cubits by passing them as parameters. + /// + /// Example: + /// ```dart + /// await tester.pumpWidget( + /// SettingsTestHelpers.buildSettingsWidget( + /// displayCubit: customMockCubit, + /// child: const DisplaySettings(), + /// ), + /// ); + /// ``` + static Widget buildSettingsWidget({ + DisplayConfigurationCubit? displayCubit, + RearDisplayConfigurationCubit? rearDisplayCubit, + SystemConfigurationCubit? systemCubit, + AudioConfigurationCubit? audioCubit, + GPSConfigurationCubit? gpsCubit, + DeviceInfoCubit? deviceInfoCubit, + required Widget child, + }) { + return MaterialApp( + home: Scaffold( + body: _buildSettingsProviders( + displayCubit: displayCubit, + rearDisplayCubit: rearDisplayCubit, + systemCubit: systemCubit, + audioCubit: audioCubit, + gpsCubit: gpsCubit, + deviceInfoCubit: deviceInfoCubit, + child: child, + ), + ), + ); + } + + /// Creates a simple MaterialApp wrapper for basic widget tests + /// + /// Use when you don't need any providers. + /// + /// Example: + /// ```dart + /// await tester.pumpWidget( + /// SettingsTestHelpers.buildSimpleWrapper( + /// child: const Text('Hello'), + /// ), + /// ); + /// ``` + static Widget buildSimpleWrapper({required Widget child}) { + return MaterialApp(home: Scaffold(body: child)); + } + + /// Creates a widget with custom theme for testing theme-dependent widgets + static Widget buildWithTheme({required Widget child, ThemeData? theme}) { + return MaterialApp( + theme: theme ?? ThemeData.light(), + home: Scaffold(body: child), + ); + } + + /// Sets up GetIt mocks for integration tests + /// + /// Registers necessary mocks like TAPageFactory. + /// Returns the registered mocks for further configuration. + static void setupGetItMocks({required TAPageFactory mockPageFactory}) { + final getIt = GetIt.instance; + if (getIt.isRegistered()) { + getIt.unregister(); + } + getIt.registerSingleton(mockPageFactory); + } + + /// Wraps a widget with full settings page environment including GetIt + /// + /// Use this for testing the SettingsPage itself or widgets that rely on + /// navigation or other GetIt services. + static Widget buildSettingsPageWrapper({ + DisplayConfigurationCubit? displayCubit, + RearDisplayConfigurationCubit? rearDisplayCubit, + SystemConfigurationCubit? systemCubit, + AudioConfigurationCubit? audioCubit, + GPSConfigurationCubit? gpsCubit, + DeviceInfoCubit? deviceInfoCubit, + required Widget child, + }) { + return MaterialApp( + home: _buildSettingsProviders( + displayCubit: displayCubit, + rearDisplayCubit: rearDisplayCubit, + systemCubit: systemCubit, + audioCubit: audioCubit, + gpsCubit: gpsCubit, + deviceInfoCubit: deviceInfoCubit, + child: child, + ), + ); + } +} diff --git a/test/helpers/test_fixtures.dart b/test/helpers/test_fixtures.dart new file mode 100644 index 0000000..9262f3a --- /dev/null +++ b/test/helpers/test_fixtures.dart @@ -0,0 +1,342 @@ +import 'package:tesla_android/feature/display/model/remote_display_state.dart'; +import 'package:tesla_android/feature/settings/model/device_info.dart'; +import 'package:tesla_android/feature/settings/model/system_configuration_response_body.dart'; + +/// Test fixtures - sample data for testing +class TestFixtures { + // Display State Fixtures + static final RemoteDisplayState defaultDisplayState = RemoteDisplayState( + width: 1920, + height: 1080, + density: 200, + resolutionPreset: DisplayResolutionModePreset.res832p, + renderer: DisplayRendererType.h264WebCodecs, + isResponsive: 1, + isH264: 1, + refreshRate: DisplayRefreshRatePreset.refresh30hz, + quality: DisplayQualityPreset.quality90, + isRearDisplayEnabled: 0, + isRearDisplayPrioritised: 0, + isHeadless: 0, + ); + + static final RemoteDisplayState mjpegDisplayState = RemoteDisplayState( + width: 1280, + height: 720, + density: 175, + resolutionPreset: DisplayResolutionModePreset.res720p, + renderer: DisplayRendererType.mjpeg, + isResponsive: 1, + isH264: 0, + refreshRate: DisplayRefreshRatePreset.refresh45hz, + quality: DisplayQualityPreset.quality70, + isRearDisplayEnabled: 0, + isRearDisplayPrioritised: 0, + isHeadless: 0, + ); + + // Device Info Fixtures + static final DeviceInfo rpi4DeviceInfo = DeviceInfo( + cpuTemperature: 45, + serialNumber: "RPI4TEST001", + deviceModel: "rpi4", + isCarPlayDetected: 0, + isModemDetected: 1, + releaseType: "stable", + otaUrl: "https://example.com/ota", + isGPSEnabled: 1, + ); + + static final DeviceInfo cm4DeviceInfo = DeviceInfo( + cpuTemperature: 52, + serialNumber: "CM4TEST001", + deviceModel: "cm4", + isCarPlayDetected: 1, + isModemDetected: 1, + releaseType: "stable", + otaUrl: "https://example.com/ota", + isGPSEnabled: 1, + ); + + // Configuration Fixtures + static final SystemConfigurationResponseBody defaultConfiguration = + SystemConfigurationResponseBody( + bandType: 1, + channel: 36, + channelWidth: 80, + isEnabledFlag: 1, + isOfflineModeEnabledFlag: 0, + isOfflineModeTelemetryEnabledFlag: 1, + isOfflineModeTeslaFirmwareDownloadsEnabledFlag: 1, + browserAudioIsEnabled: 1, + browserAudioVolume: 80, + isGPSEnabled: 1, + ); + + // JSON Fixtures for Testing Serialization + static const Map displayStateJson = { + 'width': 1920, + 'height': 1080, + 'density': 200, + 'resolutionPreset': 0, + 'renderer': 1, + 'isResponsive': 1, + 'isH264': 1, + 'refreshRate': 30, + 'quality': 90, + 'isRearDisplayEnabled': 0, + 'isRearDisplayPrioritised': 0, + 'isHeadless': 0, + }; + + static const Map deviceInfoJson = { + 'cpu_temperature': 45, + 'serial_number': 'TEST001', + 'device_model': 'rpi4', + 'is_modem_detected': 1, + 'is_carplay_detected': 0, + 'release_type': 'stable', + 'ota_url': 'https://example.com/ota', + 'is_gps_enabled': 1, + }; + + static const Map configurationJson = { + 'persist.tesla-android.softap.band_type': 1, + 'persist.tesla-android.softap.channel': 36, + 'persist.tesla-android.softap.channel_width': 80, + 'persist.tesla-android.softap.is_enabled': 1, + 'persist.tesla-android.offline-mode.is_enabled': 0, + 'persist.tesla-android.offline-mode.telemetry.is_enabled': 1, + 'persist.tesla-android.offline-mode.tesla-firmware-downloads': 1, + 'persist.tesla-android.browser_audio.is_enabled': 1, + 'persist.tesla-android.browser_audio.volume': 80, + 'persist.tesla-android.gps.is_active': 1, + }; + + static final systemConfiguration = SystemConfigurationResponseBody.fromJson( + configurationJson, + ); + + // Builder Pattern for Flexible Test Data Creation + + /// Builder for customizable RemoteDisplayState + static RemoteDisplayStateBuilder displayStateBuilder() { + return RemoteDisplayStateBuilder(); + } + + /// Builder for customizable DeviceInfo + static DeviceInfoBuilder deviceInfoBuilder() { + return DeviceInfoBuilder(); + } + + /// Builder for customizable SystemConfigurationResponseBody + static SystemConfigurationBuilder systemConfigurationBuilder() { + return SystemConfigurationBuilder(); + } +} + +/// Builder for RemoteDisplayState with fluent API +class RemoteDisplayStateBuilder { + int _width = 1920; + int _height = 1080; + final int _density = 200; + DisplayResolutionModePreset _resolutionPreset = + DisplayResolutionModePreset.res832p; + DisplayRendererType _renderer = DisplayRendererType.h264WebCodecs; + final int _isResponsive = 1; + int _isH264 = 1; + DisplayRefreshRatePreset _refreshRate = DisplayRefreshRatePreset.refresh30hz; + DisplayQualityPreset _quality = DisplayQualityPreset.quality90; + int _isRearDisplayEnabled = 0; + int _isRearDisplayPrioritised = 0; + int _isHeadless = 0; + + RemoteDisplayStateBuilder withResolution(int width, int height) { + _width = width; + _height = height; + return this; + } + + RemoteDisplayStateBuilder withRenderer(DisplayRendererType renderer) { + _renderer = renderer; + _isH264 = renderer == DisplayRendererType.h264WebCodecs ? 1 : 0; + return this; + } + + RemoteDisplayStateBuilder withPreset(DisplayResolutionModePreset preset) { + _resolutionPreset = preset; + return this; + } + + RemoteDisplayStateBuilder withQuality(DisplayQualityPreset quality) { + _quality = quality; + return this; + } + + RemoteDisplayStateBuilder withRefreshRate(DisplayRefreshRatePreset rate) { + _refreshRate = rate; + return this; + } + + RemoteDisplayStateBuilder withRearDisplay({ + required bool enabled, + bool prioritised = false, + }) { + _isRearDisplayEnabled = enabled ? 1 : 0; + _isRearDisplayPrioritised = prioritised ? 1 : 0; + return this; + } + + RemoteDisplayStateBuilder asHeadless() { + _isHeadless = 1; + return this; + } + + RemoteDisplayState build() { + return RemoteDisplayState( + width: _width, + height: _height, + density: _density, + resolutionPreset: _resolutionPreset, + renderer: _renderer, + isResponsive: _isResponsive, + isH264: _isH264, + refreshRate: _refreshRate, + quality: _quality, + isRearDisplayEnabled: _isRearDisplayEnabled, + isRearDisplayPrioritised: _isRearDisplayPrioritised, + isHeadless: _isHeadless, + ); + } +} + +/// Builder for DeviceInfo with fluent API +class DeviceInfoBuilder { + int _cpuTemperature = 45; + String _serialNumber = "TEST001"; + String _deviceModel = "rpi4"; + int _isCarPlayDetected = 0; + int _isModemDetected = 1; + String _releaseType = "stable"; + final String _otaUrl = "https://example.com/ota"; + int _isGPSEnabled = 1; + + DeviceInfoBuilder withTemperature(int temp) { + _cpuTemperature = temp; + return this; + } + + DeviceInfoBuilder withModel(String model) { + _deviceModel = model; + return this; + } + + DeviceInfoBuilder withSerialNumber(String serial) { + _serialNumber = serial; + return this; + } + + DeviceInfoBuilder withCarPlay(bool detected) { + _isCarPlayDetected = detected ? 1 : 0; + return this; + } + + DeviceInfoBuilder withModem(bool detected) { + _isModemDetected = detected ? 1 : 0; + return this; + } + + DeviceInfoBuilder withGPS(bool enabled) { + _isGPSEnabled = enabled ? 1 : 0; + return this; + } + + DeviceInfoBuilder withReleaseType(String type) { + _releaseType = type; + return this; + } + + DeviceInfo build() { + return DeviceInfo( + cpuTemperature: _cpuTemperature, + serialNumber: _serialNumber, + deviceModel: _deviceModel, + isCarPlayDetected: _isCarPlayDetected, + isModemDetected: _isModemDetected, + releaseType: _releaseType, + otaUrl: _otaUrl, + isGPSEnabled: _isGPSEnabled, + ); + } +} + +/// Builder for SystemConfigurationResponseBody with fluent API +class SystemConfigurationBuilder { + int _bandType = 1; + int _channel = 36; + int _channelWidth = 80; + int _isEnabledFlag = 1; + int _isOfflineModeEnabledFlag = 0; + int _isOfflineModeTelemetryEnabledFlag = 1; + int _isOfflineModeTeslaFirmwareDownloadsEnabledFlag = 1; + int _browserAudioIsEnabled = 1; + int _browserAudioVolume = 80; + int _isGPSEnabled = 1; + + SystemConfigurationBuilder withSoftAp({ + int? bandType, + int? channel, + int? channelWidth, + bool? enabled, + }) { + if (bandType != null) _bandType = bandType; + if (channel != null) _channel = channel; + if (channelWidth != null) _channelWidth = channelWidth; + if (enabled != null) _isEnabledFlag = enabled ? 1 : 0; + return this; + } + + SystemConfigurationBuilder withOfflineMode({ + bool? enabled, + bool? telemetry, + bool? firmwareDownloads, + }) { + if (enabled != null) _isOfflineModeEnabledFlag = enabled ? 1 : 0; + if (telemetry != null) { + _isOfflineModeTelemetryEnabledFlag = telemetry ? 1 : 0; + } + if (firmwareDownloads != null) { + _isOfflineModeTeslaFirmwareDownloadsEnabledFlag = firmwareDownloads + ? 1 + : 0; + } + return this; + } + + SystemConfigurationBuilder withAudio({bool? enabled, int? volume}) { + if (enabled != null) _browserAudioIsEnabled = enabled ? 1 : 0; + if (volume != null) _browserAudioVolume = volume; + return this; + } + + SystemConfigurationBuilder withGPS(bool enabled) { + _isGPSEnabled = enabled ? 1 : 0; + return this; + } + + SystemConfigurationResponseBody build() { + return SystemConfigurationResponseBody( + bandType: _bandType, + channel: _channel, + channelWidth: _channelWidth, + isEnabledFlag: _isEnabledFlag, + isOfflineModeEnabledFlag: _isOfflineModeEnabledFlag, + isOfflineModeTelemetryEnabledFlag: _isOfflineModeTelemetryEnabledFlag, + isOfflineModeTeslaFirmwareDownloadsEnabledFlag: + _isOfflineModeTeslaFirmwareDownloadsEnabledFlag, + browserAudioIsEnabled: _browserAudioIsEnabled, + browserAudioVolume: _browserAudioVolume, + isGPSEnabled: _isGPSEnabled, + ); + } +} diff --git a/test/integration/app_initialization_test.dart b/test/integration/app_initialization_test.dart new file mode 100644 index 0000000..a936a88 --- /dev/null +++ b/test/integration/app_initialization_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tesla_android/feature/display/model/remote_display_state.dart'; +import 'package:tesla_android/feature/settings/model/device_info.dart'; + +void main() { + group('Basic Smoke Tests', () { + test('RemoteDisplayState can be created and serialized', () { + final state = RemoteDisplayState( + width: 1920, + height: 1080, + density: 200, + resolutionPreset: DisplayResolutionModePreset.res832p, + renderer: DisplayRendererType.h264WebCodecs, + isResponsive: 1, + isH264: 1, + refreshRate: DisplayRefreshRatePreset.refresh30hz, + quality: DisplayQualityPreset.quality90, + isRearDisplayEnabled: 0, + isRearDisplayPrioritised: 0, + isHeadless: 0, + ); + + final json = state.toJson(); + final deserialized = RemoteDisplayState.fromJson(json); + + expect(deserialized, equals(state)); + }); + + test('DeviceInfo can be created and serialized', () { + final info = DeviceInfo( + cpuTemperature: 45, + serialNumber: "TEST001", + deviceModel: "rpi4", + isCarPlayDetected: 0, + isModemDetected: 1, + releaseType: "stable", + otaUrl: "https://example.com", + isGPSEnabled: 1, + ); + + final json = info.toJson(); + final deserialized = DeviceInfo.fromJson(json); + + expect(deserialized, equals(info)); + }); + + // Note: Full app initialization test with DI requires web environment + // which is not available in VM test environment. + // These would be better tested as: + // 1. E2E tests with actual browser + // 2. Widget tests with proper test environment setup + // 3. Manual testing with mock backend + }); +} diff --git a/test/integration/audio_flow_test.dart b/test/integration/audio_flow_test.dart new file mode 100644 index 0000000..edfccfd --- /dev/null +++ b/test/integration/audio_flow_test.dart @@ -0,0 +1,192 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/feature/settings/bloc/audio_configuration_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/audio_configuration_state.dart'; +import 'package:tesla_android/feature/settings/widget/sound_settings.dart'; + +import '../helpers/cubit_builders.dart'; +import '../helpers/settings_test_helpers.dart'; + +void main() { + late AudioConfigurationCubit mockCubit; + + setUp(() { + mockCubit = CubitBuilders.buildAudioConfigurationCubit(); + }); + + testWidgets('SoundSettings volume slider displays current value', ( + WidgetTester tester, + ) async { + // Arrange + when(mockCubit.state).thenReturn( + AudioConfigurationStateSettingsFetched(isEnabled: true, volume: 75), + ); + + // Act + await tester.pumpWidget( + SettingsTestHelpers.buildSettingsWidget( + audioCubit: mockCubit, + child: const SoundSettings(), + ), + ); + await tester.pump(); + + // Assert - find the slider and verify value + final slider = find.byType(Slider); + expect(slider, findsOneWidget); + + final sliderWidget = tester.widget(slider); + expect(sliderWidget.value, 75.0); + }); + + testWidgets('SoundSettings audio enable/disable toggle', ( + WidgetTester tester, + ) async { + // Arrange - audio initially disabled + when(mockCubit.state).thenReturn( + AudioConfigurationStateSettingsFetched(isEnabled: false, volume: 100), + ); + + // Act + await tester.pumpWidget( + SettingsTestHelpers.buildSettingsWidget( + audioCubit: mockCubit, + child: const SoundSettings(), + ), + ); + await tester.pump(); + + // Find and tap the switch + final audioSwitch = find.byType(Switch); + expect(audioSwitch, findsOneWidget); + + // Verify switch shows disabled state + final switchWidget = tester.widget(audioSwitch); + expect(switchWidget.value, false); + + // Act - enable audio + await tester.tap(audioSwitch); + await tester.pump(); + + // Assert - verify setState was called + verify(mockCubit.setState(true)).called(1); + }); + + testWidgets('SoundSettings slider interaction updates volume', ( + WidgetTester tester, + ) async { + // Arrange + when(mockCubit.state).thenReturn( + AudioConfigurationStateSettingsFetched(isEnabled: true, volume: 50), + ); + + // Act + await tester.pumpWidget( + SettingsTestHelpers.buildSettingsWidget( + audioCubit: mockCubit, + child: const SoundSettings(), + ), + ); + await tester.pump(); + + // Find slider + final slider = find.byType(Slider); + expect(slider, findsOneWidget); + + // Note: Testing actual slider drag is complex in widget tests + // We verify the widget has the correct onChanged callback + final sliderWidget = tester.widget(slider); + expect(sliderWidget.onChanged, isNotNull); + }); + + testWidgets('SoundSettings shows loading indicator', ( + WidgetTester tester, + ) async { + // Arrange + when(mockCubit.state).thenReturn(AudioConfigurationStateLoading()); + + // Act + await tester.pumpWidget( + SettingsTestHelpers.buildSettingsWidget( + audioCubit: mockCubit, + child: const SoundSettings(), + ), + ); + + // Assert - at least one loading indicator should be present + expect(find.byType(CircularProgressIndicator), findsWidgets); + }); + + testWidgets('SoundSettings handles error state', (WidgetTester tester) async { + // Arrange + when(mockCubit.state).thenReturn(AudioConfigurationStateError()); + + // Act + await tester.pumpWidget( + SettingsTestHelpers.buildSettingsWidget( + audioCubit: mockCubit, + child: const SoundSettings(), + ), + ); + + // Assert - at least one "Service error" text should be present + expect(find.text('Service error'), findsWidgets); + }); + + testWidgets('SoundSettings handles update in progress state', ( + WidgetTester tester, + ) async { + // Arrange + when( + mockCubit.state, + ).thenReturn(AudioConfigurationStateSettingsUpdateInProgress()); + + // Act + await tester.pumpWidget( + SettingsTestHelpers.buildSettingsWidget( + audioCubit: mockCubit, + child: const SoundSettings(), + ), + ); + + // Assert - should show loading indicator during update + expect(find.byType(CircularProgressIndicator), findsWidgets); + }); + + testWidgets('SoundSettings state transition from disabled to enabled', ( + WidgetTester tester, + ) async { + // Arrange - start disabled + when(mockCubit.state).thenReturn( + AudioConfigurationStateSettingsFetched(isEnabled: false, volume: 100), + ); + + when(mockCubit.stream).thenAnswer( + (_) => Stream.fromIterable([ + AudioConfigurationStateSettingsFetched(isEnabled: false, volume: 100), + AudioConfigurationStateSettingsUpdateInProgress(), + AudioConfigurationStateSettingsFetched(isEnabled: true, volume: 100), + ]), + ); + + // Act + await tester.pumpWidget( + SettingsTestHelpers.buildSettingsWidget( + audioCubit: mockCubit, + child: const SoundSettings(), + ), + ); + + // Initial state - switch off + final audioSwitch = find.byType(Switch); + Switch switchWidget = tester.widget(audioSwitch); + expect(switchWidget.value, false); + + // Wait for state transitions + await tester.pumpAndSettle(); + + // Assert - should eventually show enabled + expect(find.byType(Switch), findsOneWidget); + }); +} diff --git a/test/integration/connectivity_flow_test.dart b/test/integration/connectivity_flow_test.dart new file mode 100644 index 0000000..5d7106c --- /dev/null +++ b/test/integration/connectivity_flow_test.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/feature/connectivityCheck/cubit/connectivity_check_cubit.dart'; +import 'package:tesla_android/feature/connectivityCheck/model/connectivity_state.dart'; +import 'package:tesla_android/feature/display/cubit/display_cubit.dart'; +import 'package:tesla_android/feature/display/cubit/display_state.dart'; +import 'package:tesla_android/feature/display/model/remote_display_state.dart'; + +import '../helpers/cubit_builders.dart'; + +void main() { + group('Connectivity Flow', () { + late ConnectivityCheckCubit mockConnectivityCubit; + late DisplayCubit mockDisplayCubit; + + setUp(() { + mockConnectivityCubit = CubitBuilders.buildConnectivityCheckCubit(); + mockDisplayCubit = CubitBuilders.buildDisplayCubit(); + }); + + testWidgets('displays connected indicator when backend accessible', ( + WidgetTester tester, + ) async { + when( + mockConnectivityCubit.state, + ).thenReturn(ConnectivityState.backendAccessible); + + await tester.pumpWidget( + MaterialApp( + home: BlocProvider.value( + value: mockConnectivityCubit, + child: BlocBuilder( + builder: (context, state) { + return state == ConnectivityState.backendAccessible + ? const Icon(Icons.check_circle, key: Key('connected')) + : const Icon(Icons.error, key: Key('disconnected')); + }, + ), + ), + ), + ); + + expect(find.byKey(const Key('connected')), findsOneWidget); + expect(find.byKey(const Key('disconnected')), findsNothing); + }); + + testWidgets('displays error indicator when backend not accessible', ( + WidgetTester tester, + ) async { + when( + mockConnectivityCubit.state, + ).thenReturn(ConnectivityState.backendUnreachable); + + await tester.pumpWidget( + MaterialApp( + home: BlocProvider.value( + value: mockConnectivityCubit, + child: BlocBuilder( + builder: (context, state) { + return state == ConnectivityState.backendAccessible + ? const Icon(Icons.check_circle, key: Key('connected')) + : const Icon(Icons.error, key: Key('disconnected')); + }, + ), + ), + ), + ); + + expect(find.byKey(const Key('disconnected')), findsOneWidget); + expect(find.byKey(const Key('connected')), findsNothing); + }); + + testWidgets('display state reflects loading state correctly', ( + WidgetTester tester, + ) async { + when(mockDisplayCubit.state).thenReturn(DisplayStateInitial()); + + await tester.pumpWidget( + MaterialApp( + home: BlocProvider.value( + value: mockDisplayCubit, + child: BlocBuilder( + builder: (context, state) { + if (state is DisplayStateInitial) { + return const CircularProgressIndicator(key: Key('loading')); + } else if (state is DisplayStateNormal) { + return const Text('Display Ready', key: Key('ready')); + } + return const SizedBox(); + }, + ), + ), + ), + ); + + expect(find.byKey(const Key('loading')), findsOneWidget); + }); + + testWidgets('display state shows ready when normal', ( + WidgetTester tester, + ) async { + when(mockDisplayCubit.state).thenReturn( + DisplayStateNormal( + viewSize: const Size(1280, 720), + adjustedSize: const Size(1280, 720), + rendererType: DisplayRendererType.mjpeg, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: BlocProvider.value( + value: mockDisplayCubit, + child: BlocBuilder( + builder: (context, state) { + if (state is DisplayStateInitial) { + return const CircularProgressIndicator(key: Key('loading')); + } else if (state is DisplayStateNormal) { + return const Text('Display Ready', key: Key('ready')); + } + return const SizedBox(); + }, + ), + ), + ), + ); + + expect(find.byKey(const Key('ready')), findsOneWidget); + }); + }); +} diff --git a/test/integration/device_info_flow_test.dart b/test/integration/device_info_flow_test.dart new file mode 100644 index 0000000..ec64bfc --- /dev/null +++ b/test/integration/device_info_flow_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/feature/settings/bloc/device_info_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/device_info_state.dart'; +import 'package:tesla_android/feature/settings/model/device_info.dart'; +import 'package:tesla_android/feature/settings/widget/device_settings.dart'; + +import '../helpers/cubit_builders.dart'; +import '../helpers/settings_test_helpers.dart'; + +void main() { + late DeviceInfoCubit mockDeviceCubit; + + setUp(() { + mockDeviceCubit = CubitBuilders.buildDeviceInfoCubit(); + }); + + testWidgets('DeviceSettings displays correct info from cubit', ( + WidgetTester tester, + ) async { + // Arrange + final testInfo = DeviceInfo( + cpuTemperature: 45, + deviceModel: 'rpi4', + serialNumber: 'TA-123456', + isCarPlayDetected: 1, + isModemDetected: 0, + releaseType: 'Stable', + otaUrl: 'http://ota.url', + isGPSEnabled: 1, + ); + + when( + mockDeviceCubit.state, + ).thenReturn(DeviceInfoStateLoaded(deviceInfo: testInfo)); + + // Act + await tester.pumpWidget( + SettingsTestHelpers.buildSettingsWidget( + deviceInfoCubit: mockDeviceCubit, + child: const DeviceSettings(), + ), + ); + await tester.pump(); + + // Assert + expect(find.text('45°C'), findsOneWidget); + expect(find.text('Raspberry Pi 4'), findsOneWidget); + expect(find.text('TA-123456'), findsOneWidget); + expect(find.text('Connected'), findsOneWidget); // CarPlay detected + expect(find.text('Not detected'), findsOneWidget); // Modem not detected + expect(find.text('Stable'), findsOneWidget); + }); + + testWidgets('DeviceSettings shows loading indicator initially', ( + WidgetTester tester, + ) async { + // Arrange + when(mockDeviceCubit.state).thenReturn(DeviceInfoStateLoading()); + + // Act + await tester.pumpWidget( + SettingsTestHelpers.buildSettingsWidget( + deviceInfoCubit: mockDeviceCubit, + child: const DeviceSettings(), + ), + ); + + // Assert + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); +} diff --git a/test/integration/gps_flow_test.dart b/test/integration/gps_flow_test.dart new file mode 100644 index 0000000..f96ab9e --- /dev/null +++ b/test/integration/gps_flow_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/feature/settings/bloc/gps_configuration_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/gps_configuration_state.dart'; +import 'package:tesla_android/feature/settings/widget/gps_settings.dart'; + +import '../helpers/cubit_builders.dart'; +import '../helpers/settings_test_helpers.dart'; + +void main() { + late GPSConfigurationCubit mockGpsCubit; + + setUp(() { + mockGpsCubit = CubitBuilders.buildGPSConfigurationCubit(); + }); + + testWidgets('GpsSettings allows toggling GPS on/off', ( + WidgetTester tester, + ) async { + // Arrange - GPS initially enabled + when( + mockGpsCubit.state, + ).thenReturn(GPSConfigurationStateLoaded(isGPSEnabled: true)); + + // Act + await tester.pumpWidget( + SettingsTestHelpers.buildSettingsWidget( + gpsCubit: mockGpsCubit, + child: const GpsSettings(), + ), + ); + await tester.pump(); + + // Assert - verify switch shows GPS enabled + final gpsSwitch = find.byType(Switch); + expect(gpsSwitch, findsOneWidget); + + Switch switchWidget = tester.widget(gpsSwitch); + expect(switchWidget.value, true); + + // Act - toggle GPS off + await tester.tap(gpsSwitch); + await tester.pump(); + + // Assert - verify setState was called with false + verify(mockGpsCubit.setState(false)).called(1); + }); + + testWidgets('GpsSettings shows loading indicator during update', ( + WidgetTester tester, + ) async { + // Arrange + when( + mockGpsCubit.state, + ).thenReturn(GPSConfigurationStateUpdateInProgress(isGPSEnabled: true)); + + // Act + await tester.pumpWidget( + SettingsTestHelpers.buildSettingsWidget( + gpsCubit: mockGpsCubit, + child: const GpsSettings(), + ), + ); + + // Assert + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('GpsSettings shows error message on failure', ( + WidgetTester tester, + ) async { + // Arrange + when(mockGpsCubit.state).thenReturn(GPSConfigurationStateError()); + + // Act + await tester.pumpWidget( + SettingsTestHelpers.buildSettingsWidget( + gpsCubit: mockGpsCubit, + child: const GpsSettings(), + ), + ); + + // Assert + expect(find.text('Service error'), findsOneWidget); + }); +} diff --git a/test/integration/rear_display_flow_test.dart b/test/integration/rear_display_flow_test.dart new file mode 100644 index 0000000..e435fc4 --- /dev/null +++ b/test/integration/rear_display_flow_test.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/feature/settings/bloc/rear_display_configuration_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/rear_display_configuration_state.dart'; +import 'package:tesla_android/feature/settings/widget/rear_display_settings.dart'; + +import '../helpers/cubit_builders.dart'; +import '../helpers/settings_test_helpers.dart'; + +void main() { + late RearDisplayConfigurationCubit mockCubit; + + setUp(() { + mockCubit = CubitBuilders.buildRearDisplayConfigurationCubit(); + }); + + testWidgets('RearDisplaySettings allows toggling all three switches', ( + WidgetTester tester, + ) async { + // Arrange - all settings initially enabled + when(mockCubit.state).thenReturn( + RearDisplayConfigurationStateSettingsFetched( + isRearDisplayEnabled: true, + isRearDisplayPrioritised: true, + isCurrentDisplayPrimary: true, + ), + ); + + // Act + await tester.pumpWidget( + SettingsTestHelpers.buildSettingsWidget( + rearDisplayCubit: mockCubit, + child: const RearDisplaySettings(), + ), + ); + await tester.pump(); + + // Assert - all three switches should be visible + final switches = find.byType(Switch); + expect(switches, findsNWidgets(3)); + + // Act - toggle rear display off + await tester.tap(switches.first); + await tester.pump(); + + // Assert - verify setRearDisplayState was called + verify(mockCubit.setRearDisplayState(false)).called(1); + }); + + testWidgets('RearDisplaySettings hides conditional settings when disabled', ( + WidgetTester tester, + ) async { + // Arrange - rear display disabled + when(mockCubit.state).thenReturn( + RearDisplayConfigurationStateSettingsFetched( + isRearDisplayEnabled: false, + isRearDisplayPrioritised: false, + isCurrentDisplayPrimary: true, + ), + ); + + // Act + await tester.pumpWidget( + SettingsTestHelpers.buildSettingsWidget( + rearDisplayCubit: mockCubit, + child: const RearDisplaySettings(), + ), + ); + await tester.pump(); + + // Assert - only one switch (Rear Display Support) should be visible + final switches = find.byType(Switch); + expect(switches, findsOneWidget); + + // Primary Display and Priority settings should not be shown + expect(find.text('Primary Display'), findsNothing); + expect(find.text('Rear Display Priority'), findsNothing); + }); + + testWidgets('RearDisplaySettings shows loading indicators correctly', ( + WidgetTester tester, + ) async { + // Arrange + when(mockCubit.state).thenReturn(RearDisplayConfigurationStateLoading()); + + // Act + await tester.pumpWidget( + SettingsTestHelpers.buildSettingsWidget( + rearDisplayCubit: mockCubit, + child: const RearDisplaySettings(), + ), + ); + + // Assert + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('RearDisplaySettings shows error message on failure', ( + WidgetTester tester, + ) async { + // Arrange + when(mockCubit.state).thenReturn(RearDisplayConfigurationStateError()); + + // Act + await tester.pumpWidget( + SettingsTestHelpers.buildSettingsWidget( + rearDisplayCubit: mockCubit, + child: const RearDisplaySettings(), + ), + ); + + // Assert + expect(find.text('Service error'), findsOneWidget); + }); + + testWidgets( + 'RearDisplaySettings handles state transition from loading to fetched', + (WidgetTester tester) async { + // Arrange - start with loading + when(mockCubit.state).thenReturn(RearDisplayConfigurationStateLoading()); + when(mockCubit.stream).thenAnswer( + (_) => Stream.fromIterable([ + RearDisplayConfigurationStateLoading(), + RearDisplayConfigurationStateSettingsFetched( + isRearDisplayEnabled: true, + isRearDisplayPrioritised: false, + isCurrentDisplayPrimary: true, + ), + ]), + ); + + // Act + await tester.pumpWidget( + SettingsTestHelpers.buildSettingsWidget( + rearDisplayCubit: mockCubit, + child: const RearDisplaySettings(), + ), + ); + + // Initial loading state + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + // Wait for state transition + await tester.pumpAndSettle(); + + // Assert - should now show switches + expect(find.byType(Switch), findsWidgets); + }, + ); +} diff --git a/test/integration/settings_flow_test.dart b/test/integration/settings_flow_test.dart new file mode 100644 index 0000000..4a45f40 --- /dev/null +++ b/test/integration/settings_flow_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/common/service/dialog_service.dart'; +import 'package:tesla_android/feature/settings/bloc/system_configuration_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/system_configuration_state.dart'; +import 'package:tesla_android/feature/settings/model/system_configuration_response_body.dart'; +import 'package:tesla_android/feature/settings/widget/hotspot_settings.dart'; + +import '../helpers/cubit_builders.dart'; +import '../helpers/mock_services.mocks.dart'; +import '../helpers/settings_test_helpers.dart'; + +void main() { + late MockDialogService mockDialogService; + late SystemConfigurationCubit mockSystemCubit; + + setUp(() { + mockDialogService = MockDialogService(); + mockSystemCubit = CubitBuilders.buildSystemConfigurationCubit(); + + final getIt = GetIt.instance; + if (getIt.isRegistered()) { + getIt.unregister(); + } + getIt.registerSingleton(mockDialogService); + }); + + tearDown(() { + GetIt.instance.reset(); + }); + + testWidgets('HotspotSettings shows banner when settings are modified', ( + WidgetTester tester, + ) async { + // Arrange + final testConfig = SystemConfigurationResponseBody( + bandType: 1, + channel: 6, + channelWidth: 2, + isEnabledFlag: 1, + isOfflineModeEnabledFlag: 1, + isOfflineModeTelemetryEnabledFlag: 0, + isOfflineModeTeslaFirmwareDownloadsEnabledFlag: 1, + browserAudioIsEnabled: 1, + browserAudioVolume: 100, + isGPSEnabled: 1, + ); + + when(mockSystemCubit.state).thenReturn( + SystemConfigurationStateSettingsFetched(currentConfiguration: testConfig), + ); + + when(mockSystemCubit.stream).thenAnswer( + (_) => Stream.fromIterable([ + SystemConfigurationStateSettingsFetched( + currentConfiguration: testConfig, + ), + SystemConfigurationStateSettingsModified( + currentConfiguration: testConfig, + newBandType: testConfig.currentSoftApBandType, + isSoftApEnabled: true, + isOfflineModeEnabled: false, // Changed value + isOfflineModeTelemetryEnabled: false, + isOfflineModeTeslaFirmwareDownloadsEnabled: true, + ), + ]), + ); + + // Act + await tester.pumpWidget( + SettingsTestHelpers.buildSettingsWidget( + systemCubit: mockSystemCubit, + child: const HotspotSettings(), + ), + ); + await tester.pump(); // Process initial state + await tester.pump(); // Process stream update + + // Assert + verify( + mockDialogService.showMaterialBanner( + context: anyNamed('context'), + banner: anyNamed('banner'), + ), + ).called(1); + }); +} diff --git a/test/integration/user_issues_test.dart b/test/integration/user_issues_test.dart new file mode 100644 index 0000000..85459c9 --- /dev/null +++ b/test/integration/user_issues_test.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/feature/settings/bloc/audio_configuration_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/audio_configuration_state.dart'; +import 'package:tesla_android/feature/settings/bloc/gps_configuration_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/gps_configuration_state.dart'; +import 'package:tesla_android/feature/settings/bloc/system_configuration_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/system_configuration_state.dart'; +import 'package:tesla_android/feature/settings/widget/gps_settings.dart'; +import 'package:tesla_android/feature/settings/widget/hotspot_settings.dart'; +import 'package:tesla_android/feature/settings/widget/sound_settings.dart'; + +import '../helpers/cubit_builders.dart'; +import '../helpers/settings_test_helpers.dart'; +import '../helpers/test_fixtures.dart'; + +/// User Reported Issues & Edge Cases Regression Tests +/// +/// Based on community feedback (Reddit/GitHub), these tests cover common failure modes: +/// +/// 1. Audio +/// - Issue: App defaults to mute on launch. +/// - Test: Verify initial volume state and UI reflection (zero/disabled). +/// - Report: "Every time I launch the android app the audio it is muted." +/// +/// 2. Navigation & GPS +/// - Issue: GPS continuity gaps or instability. +/// - Test: Toggle stability and immediate state reflection. +/// - Report: "Test the gps on Waze... check out updated functionality." +/// +/// 3. Connectivity (Hotspot) +/// - Issue: Confusion over default Wi-Fi passwords/settings. +/// - Test: Ensure critical settings (Band, Offline Mode) are visible even if defaults are ambiguous. +/// - Report: "Default wifi was changed... I do not have the password." +/// +/// 4. Pending Coverage (Hardware/Connectivity) +/// - Ethernet via LAN IP: Verify reachability. +/// - Browser Stability: Stress test WebSocket/WebRTC. +/// - Hardware: CarPlay/LTE module detection & RPi400 support. + +void main() { + group('User Reported Issues Regression Tests', () { + late AudioConfigurationCubit mockAudioCubit; + late GPSConfigurationCubit mockGpsCubit; + late SystemConfigurationCubit mockSystemCubit; + + setUp(() { + mockAudioCubit = CubitBuilders.buildAudioConfigurationCubit(); + mockGpsCubit = CubitBuilders.buildGPSConfigurationCubit(); + mockSystemCubit = CubitBuilders.buildSystemConfigurationCubit(); + }); + + // Issue: "Every time I launch the android app the audio it is muted." + // Goal: Verify UI behavior when audio state initializes to disabled/0 volume. + testWidgets('Audio: UI handles zero volume/disabled state correctly', ( + WidgetTester tester, + ) async { + // Arrange: Simulate the "muted on launch" state + when(mockAudioCubit.state).thenReturn( + AudioConfigurationStateSettingsFetched(isEnabled: false, volume: 0), + ); + + // Act + await tester.pumpWidget( + SettingsTestHelpers.buildSettingsWidget( + audioCubit: mockAudioCubit, + child: const SoundSettings(), + ), + ); + await tester.pump(); + + // Assert: Verify visual indicators of mute state + final switchWidget = tester.widget(find.byType(Switch)); + expect(switchWidget.value, false, reason: "Audio switch should be off"); + + final sliderWidget = tester.widget(find.byType(Slider)); + expect(sliderWidget.value, 0.0, reason: "Volume slider should be at 0"); + }); + + // Issue: "GPS functionality... checks out... will test the gps on Waze" + // Goal: Ensure GPS setting toggle is robust and reflects state immediately + testWidgets('GPS: Toggle rapid interaction stability', ( + WidgetTester tester, + ) async { + // Arrange + when( + mockGpsCubit.state, + ).thenReturn(GPSConfigurationStateLoaded(isGPSEnabled: true)); + + await tester.pumpWidget( + SettingsTestHelpers.buildSettingsWidget( + gpsCubit: mockGpsCubit, + child: const GpsSettings(), + ), + ); + await tester.pump(); + + final gpsSwitch = find.byType(Switch); + expect(tester.widget(gpsSwitch).value, true); + + // Act: Toggle OFF + await tester.tap(gpsSwitch); + await tester.pump(); + + // Assert: Verify cubit call + verify(mockGpsCubit.setState(false)).called(1); + + // Note: Full UI round-trip testing with Mocks requires accurate Stream mocking. + // For this regression test, verifying the interaction (setState) is sufficient + // to prove the switch remains interactive and logic flows correctly. + }); + + // Issue: "I believe the default wifi was changed... I do not have the password" + // Goal: Verify Hotspot settings display available options even if config might be ambiguous + testWidgets('Hotspot: Handles configuration display', ( + WidgetTester tester, + ) async { + // Arrange + final config = TestFixtures.systemConfiguration; + final state = SystemConfigurationStateSettingsFetched( + currentConfiguration: config, + ); + + when(mockSystemCubit.state).thenReturn(state); + when(mockSystemCubit.stream).thenAnswer((_) => Stream.value(state)); + + // Act + await tester.pumpWidget( + SettingsTestHelpers.buildSettingsWidget( + systemCubit: mockSystemCubit, + child: const HotspotSettings(), + ), + ); + await tester.pumpAndSettle(); + + // Assert + expect( + find.text('Frequency band and channel'), + findsOneWidget, + reason: "Should show band settings", + ); + // Note: If we had a password field, we would assert its visibility here. + // Since we don't, we ensure the existing critical settings are rendered. + expect(find.text('Offline mode'), findsOneWidget); + }); + }); +} diff --git a/test/unit/cubits/audio_configuration_cubit_test.dart b/test/unit/cubits/audio_configuration_cubit_test.dart new file mode 100644 index 0000000..2a5f8b8 --- /dev/null +++ b/test/unit/cubits/audio_configuration_cubit_test.dart @@ -0,0 +1,233 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/feature/settings/bloc/audio_configuration_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/audio_configuration_state.dart'; +import 'package:tesla_android/feature/settings/repository/system_configuration_repository.dart'; +import '../../helpers/test_fixtures.dart'; + +import 'audio_configuration_cubit_test.mocks.dart'; + +@GenerateMocks([SystemConfigurationRepository]) +void main() { + late MockSystemConfigurationRepository mockRepository; + + setUp(() { + mockRepository = MockSystemConfigurationRepository(); + }); + + group('AudioConfigurationCubit', () { + test('initial state is AudioConfigurationStateInitial', () { + final cubit = AudioConfigurationCubit(mockRepository); + expect(cubit.state, isA()); + cubit.close(); + }); + + blocTest( + 'fetchConfiguration emits loaded state with audio enabled', + build: () { + final config = TestFixtures.defaultConfiguration.copyWith( + browserAudioIsEnabled: 1, + browserAudioVolume: 80, + ); + when(mockRepository.getConfiguration()).thenAnswer((_) async => config); + return AudioConfigurationCubit(mockRepository); + }, + act: (cubit) => cubit.fetchConfiguration(), + expect: () => [ + isA(), + isA() + .having((s) => s.isEnabled, 'isEnabled', true) + .having((s) => s.volume, 'volume', 80), + ], + ); + + blocTest( + 'fetchConfiguration emits loaded state with audio disabled', + build: () { + final config = TestFixtures.defaultConfiguration.copyWith( + browserAudioIsEnabled: 0, + browserAudioVolume: 50, + ); + when(mockRepository.getConfiguration()).thenAnswer((_) async => config); + return AudioConfigurationCubit(mockRepository); + }, + act: (cubit) => cubit.fetchConfiguration(), + expect: () => [ + isA(), + isA() + .having((s) => s.isEnabled, 'isEnabled', false) + .having((s) => s.volume, 'volume', 50), + ], + ); + + blocTest( + 'fetchConfiguration emits error state on failure', + build: () { + when( + mockRepository.getConfiguration(), + ).thenThrow(Exception('Network error')); + return AudioConfigurationCubit(mockRepository); + }, + act: (cubit) => cubit.fetchConfiguration(), + expect: () => [ + isA(), + isA(), + ], + ); + + blocTest( + 'setVolume updates volume and enables audio', + build: () { + when( + mockRepository.setBrowserAudioState(1), + ).thenAnswer((_) async => {}); + when( + mockRepository.setBrowserAudioVolume(60), + ).thenAnswer((_) async => {}); + return AudioConfigurationCubit(mockRepository); + }, + act: (cubit) => cubit.setVolume(60), + expect: () => [ + isA(), + isA() + .having((s) => s.isEnabled, 'isEnabled', true) + .having((s) => s.volume, 'volume', 60), + ], + verify: (_) { + verify(mockRepository.setBrowserAudioState(1)).called(1); + verify(mockRepository.setBrowserAudioVolume(60)).called(1); + }, + ); + + blocTest( + 'setState enables audio with volume 100', + build: () { + when( + mockRepository.setBrowserAudioState(1), + ).thenAnswer((_) async => {}); + when( + mockRepository.setBrowserAudioVolume(100), + ).thenAnswer((_) async => {}); + return AudioConfigurationCubit(mockRepository); + }, + act: (cubit) => cubit.setState(true), + expect: () => [ + isA(), + isA() + .having((s) => s.isEnabled, 'isEnabled', true) + .having((s) => s.volume, 'volume', 100), + ], + verify: (_) { + verify(mockRepository.setBrowserAudioState(1)).called(1); + verify(mockRepository.setBrowserAudioVolume(100)).called(1); + }, + ); + + blocTest( + 'setState disables audio', + build: () { + when( + mockRepository.setBrowserAudioState(0), + ).thenAnswer((_) async => {}); + when( + mockRepository.setBrowserAudioVolume(100), + ).thenAnswer((_) async => {}); + return AudioConfigurationCubit(mockRepository); + }, + act: (cubit) => cubit.setState(false), + expect: () => [ + isA(), + isA() + .having((s) => s.isEnabled, 'isEnabled', false) + .having((s) => s.volume, 'volume', 100), + ], + verify: (_) { + verify(mockRepository.setBrowserAudioState(0)).called(1); + verify(mockRepository.setBrowserAudioVolume(100)).called(1); + }, + ); + + blocTest( + 'setVolume emits error state on failure', + build: () { + when( + mockRepository.setBrowserAudioState(any), + ).thenThrow(Exception('Update failed')); + return AudioConfigurationCubit(mockRepository); + }, + act: (cubit) => cubit.setVolume(75), + expect: () => [ + isA(), + isA(), + ], + ); + + blocTest( + 'setState emits error state on failure', + build: () { + when( + mockRepository.setBrowserAudioState(any), + ).thenThrow(Exception('Update failed')); + return AudioConfigurationCubit(mockRepository); + }, + act: (cubit) => cubit.setState(true), + expect: () => [ + isA(), + isA(), + ], + ); + + blocTest( + 'can update volume multiple times', + build: () { + when( + mockRepository.setBrowserAudioState(any), + ).thenAnswer((_) async => {}); + when( + mockRepository.setBrowserAudioVolume(any), + ).thenAnswer((_) async => {}); + return AudioConfigurationCubit(mockRepository); + }, + act: (cubit) async { + cubit.setVolume(30); + await Future.delayed(const Duration(milliseconds: 100)); + cubit.setVolume(60); + await Future.delayed(const Duration(milliseconds: 100)); + cubit.setVolume(90); + }, + expect: () => [ + isA(), + isA().having( + (s) => s.volume, + 'volume', + 30, + ), + isA(), + isA().having( + (s) => s.volume, + 'volume', + 60, + ), + isA(), + isA().having( + (s) => s.volume, + 'volume', + 90, + ), + ], + ); + + test('cubit can be closed properly', () async { + when( + mockRepository.getConfiguration(), + ).thenAnswer((_) async => TestFixtures.defaultConfiguration); + + final cubit = AudioConfigurationCubit(mockRepository); + await cubit.close(); + + expect(cubit.isClosed, true); + }); + }); +} diff --git a/test/unit/cubits/audio_configuration_cubit_test.mocks.dart b/test/unit/cubits/audio_configuration_cubit_test.mocks.dart new file mode 100644 index 0000000..3cee3f6 --- /dev/null +++ b/test/unit/cubits/audio_configuration_cubit_test.mocks.dart @@ -0,0 +1,141 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in tesla_android/test/unit/cubits/audio_configuration_cubit_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i4; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:tesla_android/feature/settings/model/system_configuration_response_body.dart' + as _i2; +import 'package:tesla_android/feature/settings/repository/system_configuration_repository.dart' + as _i3; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeSystemConfigurationResponseBody_0 extends _i1.SmartFake + implements _i2.SystemConfigurationResponseBody { + _FakeSystemConfigurationResponseBody_0( + Object parent, + Invocation parentInvocation, + ) : super(parent, parentInvocation); +} + +/// A class which mocks [SystemConfigurationRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockSystemConfigurationRepository extends _i1.Mock + implements _i3.SystemConfigurationRepository { + MockSystemConfigurationRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i4.Future<_i2.SystemConfigurationResponseBody> getConfiguration() => + (super.noSuchMethod( + Invocation.method(#getConfiguration, []), + returnValue: _i4.Future<_i2.SystemConfigurationResponseBody>.value( + _FakeSystemConfigurationResponseBody_0( + this, + Invocation.method(#getConfiguration, []), + ), + ), + ) + as _i4.Future<_i2.SystemConfigurationResponseBody>); + + @override + _i4.Future setSoftApBand(int? band) => + (super.noSuchMethod( + Invocation.method(#setSoftApBand, [band]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setSoftApChannel(int? channel) => + (super.noSuchMethod( + Invocation.method(#setSoftApChannel, [channel]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setSoftApChannelWidth(int? channelWidth) => + (super.noSuchMethod( + Invocation.method(#setSoftApChannelWidth, [channelWidth]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setSoftApState(int? isEnabledFlag) => + (super.noSuchMethod( + Invocation.method(#setSoftApState, [isEnabledFlag]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setOfflineModeState(int? isEnabledFlag) => + (super.noSuchMethod( + Invocation.method(#setOfflineModeState, [isEnabledFlag]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setOfflineModeTelemetryState(int? isEnabledFlag) => + (super.noSuchMethod( + Invocation.method(#setOfflineModeTelemetryState, [isEnabledFlag]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setOfflineModeTeslaFirmwareDownloads( + int? isEnabledFlag, + ) => + (super.noSuchMethod( + Invocation.method(#setOfflineModeTeslaFirmwareDownloads, [ + isEnabledFlag, + ]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setBrowserAudioState(int? isEnabledFlag) => + (super.noSuchMethod( + Invocation.method(#setBrowserAudioState, [isEnabledFlag]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setBrowserAudioVolume(int? volume) => + (super.noSuchMethod( + Invocation.method(#setBrowserAudioVolume, [volume]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setGPSState(int? isEnabled) => + (super.noSuchMethod( + Invocation.method(#setGPSState, [isEnabled]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); +} diff --git a/test/unit/cubits/connectivity_check_cubit_test.dart b/test/unit/cubits/connectivity_check_cubit_test.dart new file mode 100644 index 0000000..1ccc999 --- /dev/null +++ b/test/unit/cubits/connectivity_check_cubit_test.dart @@ -0,0 +1,226 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/common/network/health_service.dart'; +import 'package:tesla_android/common/service/window_service.dart'; +import 'package:tesla_android/feature/connectivityCheck/cubit/connectivity_check_cubit.dart'; +import 'package:tesla_android/feature/connectivityCheck/model/connectivity_state.dart'; + +import 'connectivity_check_cubit_test.mocks.dart'; + +@GenerateMocks([HealthService, WindowService]) +void main() { + late MockHealthService mockHealthService; + late MockWindowService mockWindowService; + + setUp(() { + mockHealthService = MockHealthService(); + mockWindowService = MockWindowService(); + }); + + group('ConnectivityCheckCubit', () { + test('initial state is backendAccessible', () { + when(mockHealthService.getHealthCheck()).thenAnswer((_) async => {}); + + final cubit = ConnectivityCheckCubit( + mockHealthService, + mockWindowService, + ); + cubit.onReloadOverride = () {}; + expect(cubit.state, ConnectivityState.backendAccessible); + cubit.close(); + }); + + blocTest( + 'checkConnectivity emits backendAccessible on success', + build: () { + when(mockHealthService.getHealthCheck()).thenAnswer((_) async { + await Future.delayed(const Duration(milliseconds: 10)); + return {}; + }); + final cubit = ConnectivityCheckCubit( + mockHealthService, + mockWindowService, + ); + cubit.onReloadOverride = () {}; + return cubit; + }, + act: (cubit) async => await cubit.checkConnectivity(), + wait: const Duration(milliseconds: 3000), + expect: () => [ + // Initial check in constructor already verified accessibility + // Manual checkConnectivity maintains state + ConnectivityState.backendAccessible, + ], + ); + + blocTest( + 'checkConnectivity emits backendUnreachable on failure', + build: () { + when(mockHealthService.getHealthCheck()).thenAnswer((_) async { + await Future.delayed(const Duration(milliseconds: 10)); + throw Exception('Network error'); + }); + final cubit = ConnectivityCheckCubit( + mockHealthService, + mockWindowService, + ); + cubit.onReloadOverride = () {}; + return cubit; + }, + act: (cubit) async => await cubit.checkConnectivity(), + wait: const Duration(milliseconds: 3000), + expect: () => [ConnectivityState.backendUnreachable], + ); + + blocTest( + 'transitions from unreachable to accessible when health check succeeds', + build: () { + // First call fails, second succeeds + var callCount = 0; + when(mockHealthService.getHealthCheck()).thenAnswer((_) async { + callCount++; + if (callCount == 1) { + throw Exception('Network error'); + } + return {}; + }); + final cubit = ConnectivityCheckCubit( + mockHealthService, + mockWindowService, + ); + cubit.onReloadOverride = () {}; + return cubit; + }, + act: (cubit) async { + await Future.delayed(const Duration(milliseconds: 3000)); + await cubit.checkConnectivity(); // This will succeed + }, + wait: const Duration(milliseconds: 3000), + expect: () => [ + ConnectivityState.backendUnreachable, + // Note: In real cubit, this triggers window.location.reload() + // which can't be tested in VM, so we just verify the state change + ConnectivityState.backendAccessible, + ], + ); + + blocTest( + 'maintains backendAccessible state on subsequent successes', + build: () { + when(mockHealthService.getHealthCheck()).thenAnswer((_) async { + await Future.delayed(const Duration(milliseconds: 10)); + return {}; + }); + final cubit = ConnectivityCheckCubit( + mockHealthService, + mockWindowService, + ); + cubit.onReloadOverride = () {}; + return cubit; + }, + act: (cubit) async { + await Future.delayed(const Duration(milliseconds: 3000)); + await cubit.checkConnectivity(); + await Future.delayed(const Duration(milliseconds: 3000)); + await cubit.checkConnectivity(); + }, + wait: const Duration(milliseconds: 3000), + expect: () => [ConnectivityState.backendAccessible], + ); + + blocTest( + 'handles intermittent failures correctly', + build: () { + var callCount = 0; + when(mockHealthService.getHealthCheck()).thenAnswer((_) async { + callCount++; + if (callCount == 2) { + throw Exception('Temporary network error'); + } + return {}; + }); + final cubit = ConnectivityCheckCubit( + mockHealthService, + mockWindowService, + ); + cubit.onReloadOverride = () {}; + return cubit; + }, + act: (cubit) async { + await Future.delayed(const Duration(milliseconds: 3000)); + await Future.delayed(const Duration(milliseconds: 3000)); + await cubit.checkConnectivity(); // Fails + await Future.delayed(const Duration(milliseconds: 3000)); + await cubit.checkConnectivity(); // Succeeds + }, + wait: const Duration(milliseconds: 3000), + expect: () => [ + ConnectivityState.backendAccessible, // Initial + ConnectivityState.backendUnreachable, // After failure + ConnectivityState.backendAccessible, // After recovery + ], + ); + + test('cubit can be closed properly', () async { + when(mockHealthService.getHealthCheck()).thenAnswer((_) async => {}); + + final cubit = ConnectivityCheckCubit( + mockHealthService, + mockWindowService, + ); + await cubit.close(); + + expect(cubit.isClosed, true); + }); + + group('Error Handling', () { + blocTest( + 'handles timeout errors as unreachable', + build: () { + when(mockHealthService.getHealthCheck()).thenAnswer((_) async { + await Future.delayed(const Duration(milliseconds: 10)); + throw TimeoutException('Request timeout'); + }); + final cubit = ConnectivityCheckCubit( + mockHealthService, + mockWindowService, + ); + cubit.onReloadOverride = () {}; + return cubit; + }, + act: (cubit) async => await cubit.checkConnectivity(), + wait: const Duration(milliseconds: 3000), + expect: () => [ConnectivityState.backendUnreachable], + ); + + blocTest( + 'handles HTTP errors as unreachable', + build: () { + when(mockHealthService.getHealthCheck()).thenAnswer((_) async { + await Future.delayed(const Duration(milliseconds: 10)); + throw Exception('HTTP 500'); + }); + final cubit = ConnectivityCheckCubit( + mockHealthService, + mockWindowService, + ); + cubit.onReloadOverride = () {}; + return cubit; + }, + act: (cubit) async => await cubit.checkConnectivity(), + wait: const Duration(milliseconds: 3000), + expect: () => [ConnectivityState.backendUnreachable], + ); + }); + }); +} + +class TimeoutException implements Exception { + final String message; + TimeoutException(this.message); + + @override + String toString() => 'TimeoutException: $message'; +} diff --git a/test/unit/cubits/connectivity_check_cubit_test.mocks.dart b/test/unit/cubits/connectivity_check_cubit_test.mocks.dart new file mode 100644 index 0000000..f9d66ae --- /dev/null +++ b/test/unit/cubits/connectivity_check_cubit_test.mocks.dart @@ -0,0 +1,56 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in tesla_android/test/unit/cubits/connectivity_check_cubit_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:tesla_android/common/network/health_service.dart' as _i2; +import 'package:tesla_android/common/service/window_service.dart' as _i4; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +/// A class which mocks [HealthService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockHealthService extends _i1.Mock implements _i2.HealthService { + MockHealthService() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future getHealthCheck() => + (super.noSuchMethod( + Invocation.method(#getHealthCheck, []), + returnValue: _i3.Future.value(), + ) + as _i3.Future); +} + +/// A class which mocks [WindowService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockWindowService extends _i1.Mock implements _i4.WindowService { + MockWindowService() { + _i1.throwOnMissingStub(this); + } + + @override + void reload() => super.noSuchMethod( + Invocation.method(#reload, []), + returnValueForMissingStub: null, + ); +} diff --git a/test/unit/cubits/device_info_cubit_test.dart b/test/unit/cubits/device_info_cubit_test.dart new file mode 100644 index 0000000..2bb7295 --- /dev/null +++ b/test/unit/cubits/device_info_cubit_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:tesla_android/feature/settings/bloc/device_info_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/device_info_state.dart'; +import 'package:tesla_android/feature/settings/repository/device_info_repository.dart'; + +import '../../helpers/test_fixtures.dart'; +import 'device_info_cubit_test.mocks.dart'; + +@GenerateMocks([DeviceInfoRepository]) +void main() { + late MockDeviceInfoRepository mockRepository; + + setUp(() { + mockRepository = MockDeviceInfoRepository(); + }); + + group('DeviceInfoCubit', () { + test('initial state is DeviceInfoStateInitial', () { + final cubit = DeviceInfoCubit(mockRepository); + expect(cubit.state, isA()); + cubit.close(); + }); + + blocTest( + 'fetchConfiguration emits Loading then Loaded on success', + build: () { + when( + mockRepository.getDeviceInfo(), + ).thenAnswer((_) async => TestFixtures.rpi4DeviceInfo); + return DeviceInfoCubit(mockRepository); + }, + act: (cubit) => cubit.fetchConfiguration(), + expect: () => [ + isA(), + isA().having( + (s) => s.deviceInfo, + 'deviceInfo', + TestFixtures.rpi4DeviceInfo, + ), + ], + verify: (cubit) { + verify(mockRepository.getDeviceInfo()).called(1); + }, + ); + + blocTest( + 'fetchConfiguration emits Loading then Error on failure', + build: () { + when( + mockRepository.getDeviceInfo(), + ).thenThrow(Exception('Network error')); + return DeviceInfoCubit(mockRepository); + }, + act: (cubit) => cubit.fetchConfiguration(), + expect: () => [ + isA(), + isA(), + ], + ); + }); +} diff --git a/test/unit/cubits/device_info_cubit_test.mocks.dart b/test/unit/cubits/device_info_cubit_test.mocks.dart new file mode 100644 index 0000000..5412939 --- /dev/null +++ b/test/unit/cubits/device_info_cubit_test.mocks.dart @@ -0,0 +1,50 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in tesla_android/test/unit/cubits/device_info_cubit_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i4; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:tesla_android/feature/settings/model/device_info.dart' as _i2; +import 'package:tesla_android/feature/settings/repository/device_info_repository.dart' + as _i3; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeDeviceInfo_0 extends _i1.SmartFake implements _i2.DeviceInfo { + _FakeDeviceInfo_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +/// A class which mocks [DeviceInfoRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockDeviceInfoRepository extends _i1.Mock + implements _i3.DeviceInfoRepository { + MockDeviceInfoRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i4.Future<_i2.DeviceInfo> getDeviceInfo() => + (super.noSuchMethod( + Invocation.method(#getDeviceInfo, []), + returnValue: _i4.Future<_i2.DeviceInfo>.value( + _FakeDeviceInfo_0(this, Invocation.method(#getDeviceInfo, [])), + ), + ) + as _i4.Future<_i2.DeviceInfo>); +} diff --git a/test/unit/cubits/display_configuration_cubit_test.dart b/test/unit/cubits/display_configuration_cubit_test.dart new file mode 100644 index 0000000..9fdb6c6 --- /dev/null +++ b/test/unit/cubits/display_configuration_cubit_test.dart @@ -0,0 +1,241 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:tesla_android/feature/display/model/remote_display_state.dart'; +import 'package:tesla_android/feature/display/repository/display_repository.dart'; +import 'package:tesla_android/feature/settings/bloc/display_configuration_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/display_configuration_state.dart'; + +import '../../helpers/test_fixtures.dart'; +import 'display_configuration_cubit_test.mocks.dart'; + +@GenerateMocks([DisplayRepository]) +void main() { + late MockDisplayRepository mockRepository; + + setUp(() { + mockRepository = MockDisplayRepository(); + }); + + group('DisplayConfigurationCubit', () { + test('initial state is DisplayConfigurationStateInitial', () { + final cubit = DisplayConfigurationCubit(mockRepository); + expect(cubit.state, isA()); + cubit.close(); + }); + + blocTest( + 'fetchConfiguration emits Loading then Fetched on success', + build: () { + when( + mockRepository.getDisplayState(), + ).thenAnswer((_) async => TestFixtures.defaultDisplayState); + return DisplayConfigurationCubit(mockRepository); + }, + act: (cubit) => cubit.fetchConfiguration(), + expect: () => [ + isA(), + isA(), + ], + verify: (cubit) { + verify(mockRepository.getDisplayState()).called(1); + }, + ); + + blocTest( + 'fetchConfiguration emits Loading then Error on failure', + build: () { + when( + mockRepository.getDisplayState(), + ).thenThrow(Exception('Network error')); + return DisplayConfigurationCubit(mockRepository); + }, + act: (cubit) => cubit.fetchConfiguration(), + expect: () => [ + isA(), + isA(), + ], + ); + + blocTest( + 'setResponsiveness updates state and repository', + build: () { + when( + mockRepository.getDisplayState(), + ).thenAnswer((_) async => TestFixtures.defaultDisplayState); + when( + mockRepository.updateDisplayConfiguration(any), + ).thenAnswer((_) async => {}); + return DisplayConfigurationCubit(mockRepository); + }, + seed: () => DisplayConfigurationStateSettingsFetched( + resolutionPreset: TestFixtures.defaultDisplayState.resolutionPreset, + renderer: TestFixtures.defaultDisplayState.renderer, + isResponsive: TestFixtures.defaultDisplayState.isResponsive == 1, + refreshRate: TestFixtures.defaultDisplayState.refreshRate, + quality: TestFixtures.defaultDisplayState.quality, + ), + act: (cubit) async { + cubit.fetchConfiguration(); + await cubit.stream.firstWhere( + (state) => state is DisplayConfigurationStateSettingsFetched, + ); + cubit.setResponsiveness(false); + }, + skip: 2, // Skip loading states from fetchConfiguration + expect: () => [ + isA(), + isA().having( + (s) => s.isResponsive, + 'isResponsive', + false, + ), + ], + verify: (cubit) { + verify(mockRepository.updateDisplayConfiguration(any)).called(1); + }, + ); + + blocTest( + 'setResolution updates state and repository', + build: () { + when( + mockRepository.getDisplayState(), + ).thenAnswer((_) async => TestFixtures.defaultDisplayState); + when( + mockRepository.updateDisplayConfiguration(any), + ).thenAnswer((_) async => {}); + return DisplayConfigurationCubit(mockRepository); + }, + act: (cubit) async { + cubit.fetchConfiguration(); + await cubit.stream.firstWhere( + (state) => state is DisplayConfigurationStateSettingsFetched, + ); + cubit.setResolution(DisplayResolutionModePreset.res720p); + }, + skip: 2, + expect: () => [ + isA(), + isA().having( + (s) => s.resolutionPreset, + 'resolutionPreset', + DisplayResolutionModePreset.res720p, + ), + ], + ); + + blocTest( + 'setRenderer updates state and repository', + build: () { + when( + mockRepository.getDisplayState(), + ).thenAnswer((_) async => TestFixtures.defaultDisplayState); + when( + mockRepository.updateDisplayConfiguration(any), + ).thenAnswer((_) async => {}); + return DisplayConfigurationCubit(mockRepository); + }, + act: (cubit) async { + cubit.fetchConfiguration(); + await cubit.stream.firstWhere( + (state) => state is DisplayConfigurationStateSettingsFetched, + ); + cubit.setRenderer(DisplayRendererType.mjpeg); + }, + skip: 2, + expect: () => [ + isA(), + isA().having( + (s) => s.renderer, + 'renderer', + DisplayRendererType.mjpeg, + ), + ], + ); + + blocTest( + 'setQuality updates state and repository', + build: () { + when( + mockRepository.getDisplayState(), + ).thenAnswer((_) async => TestFixtures.defaultDisplayState); + when( + mockRepository.updateDisplayConfiguration(any), + ).thenAnswer((_) async => {}); + return DisplayConfigurationCubit(mockRepository); + }, + act: (cubit) async { + cubit.fetchConfiguration(); + await cubit.stream.firstWhere( + (state) => state is DisplayConfigurationStateSettingsFetched, + ); + cubit.setQuality(DisplayQualityPreset.quality50); + }, + skip: 2, + expect: () => [ + isA(), + isA().having( + (s) => s.quality, + 'quality', + DisplayQualityPreset.quality50, + ), + ], + ); + + blocTest( + 'setRefreshRate updates state and repository', + build: () { + when( + mockRepository.getDisplayState(), + ).thenAnswer((_) async => TestFixtures.defaultDisplayState); + when( + mockRepository.updateDisplayConfiguration(any), + ).thenAnswer((_) async => {}); + return DisplayConfigurationCubit(mockRepository); + }, + act: (cubit) async { + cubit.fetchConfiguration(); + await cubit.stream.firstWhere( + (state) => state is DisplayConfigurationStateSettingsFetched, + ); + cubit.setRefreshRate(DisplayRefreshRatePreset.refresh60hz); + }, + skip: 2, + expect: () => [ + isA(), + isA().having( + (s) => s.refreshRate, + 'refreshRate', + DisplayRefreshRatePreset.refresh60hz, + ), + ], + ); + + blocTest( + 'setter emits Error on repository failure', + build: () { + when( + mockRepository.getDisplayState(), + ).thenAnswer((_) async => TestFixtures.defaultDisplayState); + when( + mockRepository.updateDisplayConfiguration(any), + ).thenThrow(Exception('Network error')); + return DisplayConfigurationCubit(mockRepository); + }, + act: (cubit) async { + cubit.fetchConfiguration(); + await cubit.stream.firstWhere( + (state) => state is DisplayConfigurationStateSettingsFetched, + ); + cubit.setResponsiveness(false); + }, + skip: 2, + expect: () => [ + isA(), + isA(), + ], + ); + }); +} diff --git a/test/unit/cubits/display_configuration_cubit_test.mocks.dart b/test/unit/cubits/display_configuration_cubit_test.mocks.dart new file mode 100644 index 0000000..fd65959 --- /dev/null +++ b/test/unit/cubits/display_configuration_cubit_test.mocks.dart @@ -0,0 +1,80 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in tesla_android/test/unit/cubits/display_configuration_cubit_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i4; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:tesla_android/feature/display/model/remote_display_state.dart' + as _i2; +import 'package:tesla_android/feature/display/repository/display_repository.dart' + as _i3; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeRemoteDisplayState_0 extends _i1.SmartFake + implements _i2.RemoteDisplayState { + _FakeRemoteDisplayState_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +/// A class which mocks [DisplayRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockDisplayRepository extends _i1.Mock implements _i3.DisplayRepository { + MockDisplayRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i4.Future<_i2.RemoteDisplayState> getDisplayState() => + (super.noSuchMethod( + Invocation.method(#getDisplayState, []), + returnValue: _i4.Future<_i2.RemoteDisplayState>.value( + _FakeRemoteDisplayState_0( + this, + Invocation.method(#getDisplayState, []), + ), + ), + ) + as _i4.Future<_i2.RemoteDisplayState>); + + @override + _i4.Future updateDisplayConfiguration( + _i2.RemoteDisplayState? configuration, + ) => + (super.noSuchMethod( + Invocation.method(#updateDisplayConfiguration, [configuration]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future isPrimaryDisplay() => + (super.noSuchMethod( + Invocation.method(#isPrimaryDisplay, []), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setDisplayType(bool? isPrimaryDisplay) => + (super.noSuchMethod( + Invocation.method(#setDisplayType, [isPrimaryDisplay]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); +} diff --git a/test/unit/cubits/display_cubit_test.dart b/test/unit/cubits/display_cubit_test.dart new file mode 100644 index 0000000..5a0c271 --- /dev/null +++ b/test/unit/cubits/display_cubit_test.dart @@ -0,0 +1,324 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/feature/display/cubit/display_cubit.dart'; +import 'package:tesla_android/feature/display/cubit/display_state.dart'; +import 'package:tesla_android/feature/display/model/remote_display_state.dart'; +import 'package:tesla_android/feature/display/repository/display_repository.dart'; +import '../../helpers/test_fixtures.dart'; +import 'display_cubit_test.mocks.dart'; + +@GenerateMocks([DisplayRepository]) +void main() { + late MockDisplayRepository mockRepository; + setUp(() { + mockRepository = MockDisplayRepository(); + }); + + group('DisplayCubit', () { + test('initial state is DisplayStateInitial', () { + final cubit = DisplayCubit(mockRepository); + expect(cubit.state, isA()); + cubit.close(); + }); + + blocTest( + 'resizeDisplay does nothing when current size equals new size', + build: () { + when( + mockRepository.getDisplayState(), + ).thenAnswer((_) async => TestFixtures.defaultDisplayState); + when(mockRepository.isPrimaryDisplay()).thenAnswer((_) async => true); + when( + mockRepository.updateDisplayConfiguration(any), + ).thenAnswer((_) async => {}); + return DisplayCubit(mockRepository); + }, + act: (cubit) async { + // First set to normal state with a specific size + await cubit.resizeDisplay(viewSize: const Size(1920, 1080)); + await Future.delayed(const Duration(milliseconds: 1100)); + + // Try to resize to same size - should do nothing + await cubit.resizeDisplay(viewSize: const Size(1920, 1080)); + }, + wait: const Duration(milliseconds: 3000), + expect: () => [ + isA(), + isA(), + isA(), + ], + ); + + blocTest( + 'resize Display emits ResizeCoolDown then Normal when sizes match', + build: () { + when( + mockRepository.getDisplayState(), + ).thenAnswer((_) async => TestFixtures.defaultDisplayState); + when(mockRepository.isPrimaryDisplay()).thenAnswer((_) async => true); + when( + mockRepository.updateDisplayConfiguration(any), + ).thenAnswer((_) async => {}); + return DisplayCubit(mockRepository); + }, + act: (cubit) async => + await cubit.resizeDisplay(viewSize: const Size(1920, 1080)), + wait: const Duration(milliseconds: 3000), + expect: () => [ + isA(), + isA(), + isA(), + ], + verify: (cubit) { + final state = cubit.state as DisplayStateNormal; + expect(state.viewSize, const Size(1920, 1080)); + expect(state.adjustedSize, const Size(1024, 768)); + expect(state.rendererType, DisplayRendererType.h264WebCodecs); + }, + ); + + blocTest( + 'resizeDisplay calculates optimal size when different from remote', + build: () { + // Remote state is 1920x1080, but optimal might be different + when( + mockRepository.getDisplayState(), + ).thenAnswer((_) async => TestFixtures.defaultDisplayState); + when(mockRepository.isPrimaryDisplay()).thenAnswer((_) async => true); + when( + mockRepository.updateDisplayConfiguration(any), + ).thenAnswer((_) async => {}); + return DisplayCubit(mockRepository); + }, + act: (cubit) async => + await cubit.resizeDisplay(viewSize: const Size(1280, 720)), + wait: const Duration(milliseconds: 3000), + expect: () => [ + isA(), + isA(), + isA(), + ], + verify: (cubit) { + verify(mockRepository.updateDisplayConfiguration(any)).called(1); + }, + ); + + blocTest( + 'onDisplayTypeSelectionFinished sets primary display and resizes', + build: () { + when( + mockRepository.getDisplayState(), + ).thenAnswer((_) async => TestFixtures.defaultDisplayState); + when(mockRepository.isPrimaryDisplay()).thenAnswer((_) async => true); + when(mockRepository.setDisplayType(true)).thenAnswer((_) async => true); + when( + mockRepository.updateDisplayConfiguration(any), + ).thenAnswer((_) async => {}); + return DisplayCubit(mockRepository); + }, + act: (cubit) async => + await cubit.onDisplayTypeSelectionFinished(isPrimaryDisplay: true), + wait: const Duration(milliseconds: 3000), + expect: () => [ + isA(), + isA(), + isA(), + isA(), + ], + verify: (cubit) { + verify(mockRepository.setDisplayType(true)).called(1); + }, + ); + + blocTest( + 'emits DisplayTypeSelectionTriggered when isPrimaryDisplay is null and rear display enabled', + build: () { + final rearDisplayState = TestFixtures.defaultDisplayState.copyWith( + isRearDisplayEnabled: 1, + ); + when( + mockRepository.getDisplayState(), + ).thenAnswer((_) async => rearDisplayState); + when( + mockRepository.isPrimaryDisplay(), + ).thenAnswer((_) async => null); // No preference set + return DisplayCubit(mockRepository); + }, + act: (cubit) async => + await cubit.resizeDisplay(viewSize: const Size(1920, 1080)), + wait: const Duration(milliseconds: 3000), + expect: () => [isA()], + ); + + blocTest( + 'uses MJPEG renderer when isH264 is false', + build: () { + when( + mockRepository.getDisplayState(), + ).thenAnswer((_) async => TestFixtures.mjpegDisplayState); + when(mockRepository.isPrimaryDisplay()).thenAnswer((_) async => true); + when( + mockRepository.updateDisplayConfiguration(any), + ).thenAnswer((_) async => {}); + return DisplayCubit(mockRepository); + }, + act: (cubit) async => + await cubit.resizeDisplay(viewSize: const Size(1280, 720)), + wait: const Duration(milliseconds: 3000), + expect: () => [ + isA(), + isA(), + isA(), + ], + verify: (cubit) { + final state = cubit.state as DisplayStateNormal; + expect(state.rendererType, DisplayRendererType.mjpeg); + }, + ); + + blocTest( + 'onWindowSizeChanged triggers resize with new size', + build: () { + when( + mockRepository.getDisplayState(), + ).thenAnswer((_) async => TestFixtures.defaultDisplayState); + when(mockRepository.isPrimaryDisplay()).thenAnswer((_) async => true); + when( + mockRepository.updateDisplayConfiguration(any), + ).thenAnswer((_) async => {}); + return DisplayCubit(mockRepository); + }, + act: (cubit) async => + await cubit.onWindowSizeChanged(const Size(800, 600)), + wait: const Duration(milliseconds: 3000), + expect: () => [ + isA(), + isA(), + isA(), + ], + verify: (cubit) { + final state = cubit.state as DisplayStateNormal; + expect(state.viewSize, const Size(800, 600)); + }, + ); + + blocTest( + 'does not resize when in DisplayStateResizeInProgress', + build: () { + when( + mockRepository.getDisplayState(), + ).thenAnswer((_) async => TestFixtures.defaultDisplayState); + when(mockRepository.isPrimaryDisplay()).thenAnswer((_) async => true); + when( + mockRepository.updateDisplayConfiguration(any), + ).thenAnswer((_) async => {}); + return DisplayCubit(mockRepository); + }, + act: (cubit) async { + // Start first resize + await cubit.resizeDisplay(viewSize: const Size(1280, 720)); + await Future.delayed(const Duration(milliseconds: 1100)); + + // Try second resize during InProgress state + await cubit.resizeDisplay(viewSize: const Size(800, 600)); + }, + wait: const Duration(milliseconds: 3000), + expect: () => [ + isA(), + isA(), + isA(), + // Second resize might queue or be ignored + ], + ); + + blocTest( + 'handles rear display prioritization correctly', + build: () { + final rearPrioritizedState = TestFixtures.defaultDisplayState.copyWith( + isRearDisplayEnabled: 1, + isRearDisplayPrioritised: 1, + ); + when( + mockRepository.getDisplayState(), + ).thenAnswer((_) async => rearPrioritizedState); + when( + mockRepository.isPrimaryDisplay(), + ).thenAnswer((_) async => false); // Secondary display + when( + mockRepository.updateDisplayConfiguration(any), + ).thenAnswer((_) async => {}); + return DisplayCubit(mockRepository); + }, + act: (cubit) async => + await cubit.resizeDisplay(viewSize: const Size(1920, 1080)), + wait: const Duration(milliseconds: 3000), + expect: () => [ + isA(), + isA(), + isA(), + ], + ); + + test('close cancels timers and subscriptions', () async { + when( + mockRepository.getDisplayState(), + ).thenAnswer((_) async => TestFixtures.defaultDisplayState); + when(mockRepository.isPrimaryDisplay()).thenAnswer((_) async => true); + + final cubit = DisplayCubit(mockRepository); + await cubit.resizeDisplay(viewSize: const Size(1920, 1080)); + + await Future.delayed(const Duration(milliseconds: 100)); + await cubit.close(); + + expect(cubit.isClosed, true); + }); + + group('Edge Cases', () { + blocTest( + 'handles zero viewSize by using remote size', + build: () { + when( + mockRepository.getDisplayState(), + ).thenAnswer((_) async => TestFixtures.defaultDisplayState); + when(mockRepository.isPrimaryDisplay()).thenAnswer((_) async => true); + when( + mockRepository.updateDisplayConfiguration(any), + ).thenAnswer((_) async => {}); + return DisplayCubit(mockRepository); + }, + act: (cubit) async => await cubit.resizeDisplay(viewSize: Size.zero), + wait: const Duration(milliseconds: 3000), + expect: () => [ + isA(), + isA(), + isA(), + ], + verify: (cubit) { + final state = cubit.state as DisplayStateNormal; + // Should use remote size (1920x1080 from fixture) + expect(state.viewSize.width, 1920); + expect(state.viewSize.height, 1080); + }, + ); + + blocTest( + 'handles repository errors gracefully', + build: () { + when( + mockRepository.getDisplayState(), + ).thenThrow(Exception('Network error')); + when(mockRepository.isPrimaryDisplay()).thenAnswer((_) async => true); + return DisplayCubit(mockRepository); + }, + act: (cubit) async => + await cubit.resizeDisplay(viewSize: const Size(1920, 1080)), + wait: const Duration(milliseconds: 3000), + errors: () => [isA()], + ); + }); + }); +} diff --git a/test/unit/cubits/display_cubit_test.mocks.dart b/test/unit/cubits/display_cubit_test.mocks.dart new file mode 100644 index 0000000..5b78bf3 --- /dev/null +++ b/test/unit/cubits/display_cubit_test.mocks.dart @@ -0,0 +1,80 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in tesla_android/test/unit/cubits/display_cubit_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i4; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:tesla_android/feature/display/model/remote_display_state.dart' + as _i2; +import 'package:tesla_android/feature/display/repository/display_repository.dart' + as _i3; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeRemoteDisplayState_0 extends _i1.SmartFake + implements _i2.RemoteDisplayState { + _FakeRemoteDisplayState_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +/// A class which mocks [DisplayRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockDisplayRepository extends _i1.Mock implements _i3.DisplayRepository { + MockDisplayRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i4.Future<_i2.RemoteDisplayState> getDisplayState() => + (super.noSuchMethod( + Invocation.method(#getDisplayState, []), + returnValue: _i4.Future<_i2.RemoteDisplayState>.value( + _FakeRemoteDisplayState_0( + this, + Invocation.method(#getDisplayState, []), + ), + ), + ) + as _i4.Future<_i2.RemoteDisplayState>); + + @override + _i4.Future updateDisplayConfiguration( + _i2.RemoteDisplayState? configuration, + ) => + (super.noSuchMethod( + Invocation.method(#updateDisplayConfiguration, [configuration]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future isPrimaryDisplay() => + (super.noSuchMethod( + Invocation.method(#isPrimaryDisplay, []), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setDisplayType(bool? isPrimaryDisplay) => + (super.noSuchMethod( + Invocation.method(#setDisplayType, [isPrimaryDisplay]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); +} diff --git a/test/unit/cubits/gps_configuration_cubit_test.dart b/test/unit/cubits/gps_configuration_cubit_test.dart new file mode 100644 index 0000000..81a4a87 --- /dev/null +++ b/test/unit/cubits/gps_configuration_cubit_test.dart @@ -0,0 +1,191 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/feature/settings/bloc/gps_configuration_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/gps_configuration_state.dart'; +import 'package:tesla_android/feature/settings/repository/system_configuration_repository.dart'; +import '../../helpers/test_fixtures.dart'; + +import 'gps_configuration_cubit_test.mocks.dart'; + +@GenerateMocks([SystemConfigurationRepository]) +void main() { + late MockSystemConfigurationRepository mockRepository; + + setUp(() { + mockRepository = MockSystemConfigurationRepository(); + }); + + group('GPSConfigurationCubit', () { + test('initial state is GPSConfigurationStateInitial', () { + final cubit = GPSConfigurationCubit(mockRepository); + expect(cubit.state, isA()); + cubit.close(); + }); + + blocTest( + 'fetchConfiguration emits loaded state with GPS enabled', + build: () { + final config = TestFixtures.defaultConfiguration.copyWith( + isGPSEnabled: 1, + ); + when(mockRepository.getConfiguration()).thenAnswer((_) async => config); + return GPSConfigurationCubit(mockRepository); + }, + act: (cubit) => cubit.fetchConfiguration(), + expect: () => [ + isA(), + isA().having( + (s) => s.isGPSEnabled, + 'isGPSEnabled', + true, + ), + ], + ); + + blocTest( + 'fetchConfiguration emits loaded state with GPS disabled', + build: () { + final config = TestFixtures.defaultConfiguration.copyWith( + isGPSEnabled: 0, + ); + when(mockRepository.getConfiguration()).thenAnswer((_) async => config); + return GPSConfigurationCubit(mockRepository); + }, + act: (cubit) => cubit.fetchConfiguration(), + expect: () => [ + isA(), + isA().having( + (s) => s.isGPSEnabled, + 'isGPSEnabled', + false, + ), + ], + ); + + blocTest( + 'fetchConfiguration emits error state on failure', + build: () { + when( + mockRepository.getConfiguration(), + ).thenThrow(Exception('Network error')); + return GPSConfigurationCubit(mockRepository); + }, + act: (cubit) => cubit.fetchConfiguration(), + expect: () => [ + isA(), + isA(), + ], + ); + + blocTest( + 'setState enables GPS successfully', + build: () { + when(mockRepository.setGPSState(1)).thenAnswer((_) async => {}); + return GPSConfigurationCubit(mockRepository); + }, + act: (cubit) => cubit.setState(true), + expect: () => [ + isA().having( + (s) => s.isGPSEnabled, + 'isGPSEnabled', + true, + ), + isA().having( + (s) => s.isGPSEnabled, + 'isGPSEnabled', + true, + ), + ], + verify: (_) { + verify(mockRepository.setGPSState(1)).called(1); + }, + ); + + blocTest( + 'setState disables GPS successfully', + build: () { + when(mockRepository.setGPSState(0)).thenAnswer((_) async => {}); + return GPSConfigurationCubit(mockRepository); + }, + act: (cubit) => cubit.setState(false), + expect: () => [ + isA().having( + (s) => s.isGPSEnabled, + 'isGPSEnabled', + false, + ), + isA().having( + (s) => s.isGPSEnabled, + 'isGPSEnabled', + false, + ), + ], + verify: (_) { + verify(mockRepository.setGPSState(0)).called(1); + }, + ); + + blocTest( + 'setState emits error state on failure', + build: () { + when( + mockRepository.setGPSState(any), + ).thenThrow(Exception('Update failed')); + return GPSConfigurationCubit(mockRepository); + }, + act: (cubit) => cubit.setState(true), + expect: () => [ + isA(), + isA(), + ], + ); + + blocTest( + 'can toggle GPS state multiple times', + build: () { + when(mockRepository.setGPSState(any)).thenAnswer((_) async => {}); + return GPSConfigurationCubit(mockRepository); + }, + act: (cubit) async { + cubit.setState(true); + await Future.delayed(const Duration(milliseconds: 100)); + cubit.setState(false); + await Future.delayed(const Duration(milliseconds: 100)); + cubit.setState(true); + }, + expect: () => [ + isA(), + isA().having( + (s) => s.isGPSEnabled, + 'isGPSEnabled', + true, + ), + isA(), + isA().having( + (s) => s.isGPSEnabled, + 'isGPSEnabled', + false, + ), + isA(), + isA().having( + (s) => s.isGPSEnabled, + 'isGPSEnabled', + true, + ), + ], + ); + + test('cubit can be closed properly', () async { + when( + mockRepository.getConfiguration(), + ).thenAnswer((_) async => TestFixtures.defaultConfiguration); + + final cubit = GPSConfigurationCubit(mockRepository); + await cubit.close(); + + expect(cubit.isClosed, true); + }); + }); +} diff --git a/test/unit/cubits/gps_configuration_cubit_test.mocks.dart b/test/unit/cubits/gps_configuration_cubit_test.mocks.dart new file mode 100644 index 0000000..0b8191a --- /dev/null +++ b/test/unit/cubits/gps_configuration_cubit_test.mocks.dart @@ -0,0 +1,141 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in tesla_android/test/unit/cubits/gps_configuration_cubit_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i4; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:tesla_android/feature/settings/model/system_configuration_response_body.dart' + as _i2; +import 'package:tesla_android/feature/settings/repository/system_configuration_repository.dart' + as _i3; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeSystemConfigurationResponseBody_0 extends _i1.SmartFake + implements _i2.SystemConfigurationResponseBody { + _FakeSystemConfigurationResponseBody_0( + Object parent, + Invocation parentInvocation, + ) : super(parent, parentInvocation); +} + +/// A class which mocks [SystemConfigurationRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockSystemConfigurationRepository extends _i1.Mock + implements _i3.SystemConfigurationRepository { + MockSystemConfigurationRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i4.Future<_i2.SystemConfigurationResponseBody> getConfiguration() => + (super.noSuchMethod( + Invocation.method(#getConfiguration, []), + returnValue: _i4.Future<_i2.SystemConfigurationResponseBody>.value( + _FakeSystemConfigurationResponseBody_0( + this, + Invocation.method(#getConfiguration, []), + ), + ), + ) + as _i4.Future<_i2.SystemConfigurationResponseBody>); + + @override + _i4.Future setSoftApBand(int? band) => + (super.noSuchMethod( + Invocation.method(#setSoftApBand, [band]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setSoftApChannel(int? channel) => + (super.noSuchMethod( + Invocation.method(#setSoftApChannel, [channel]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setSoftApChannelWidth(int? channelWidth) => + (super.noSuchMethod( + Invocation.method(#setSoftApChannelWidth, [channelWidth]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setSoftApState(int? isEnabledFlag) => + (super.noSuchMethod( + Invocation.method(#setSoftApState, [isEnabledFlag]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setOfflineModeState(int? isEnabledFlag) => + (super.noSuchMethod( + Invocation.method(#setOfflineModeState, [isEnabledFlag]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setOfflineModeTelemetryState(int? isEnabledFlag) => + (super.noSuchMethod( + Invocation.method(#setOfflineModeTelemetryState, [isEnabledFlag]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setOfflineModeTeslaFirmwareDownloads( + int? isEnabledFlag, + ) => + (super.noSuchMethod( + Invocation.method(#setOfflineModeTeslaFirmwareDownloads, [ + isEnabledFlag, + ]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setBrowserAudioState(int? isEnabledFlag) => + (super.noSuchMethod( + Invocation.method(#setBrowserAudioState, [isEnabledFlag]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setBrowserAudioVolume(int? volume) => + (super.noSuchMethod( + Invocation.method(#setBrowserAudioVolume, [volume]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setGPSState(int? isEnabled) => + (super.noSuchMethod( + Invocation.method(#setGPSState, [isEnabled]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); +} diff --git a/test/unit/cubits/ota_update_cubit_test.dart b/test/unit/cubits/ota_update_cubit_test.dart new file mode 100644 index 0000000..e75c66d --- /dev/null +++ b/test/unit/cubits/ota_update_cubit_test.dart @@ -0,0 +1,181 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:tesla_android/feature/home/cubit/ota_update_cubit.dart'; +import 'package:tesla_android/feature/home/cubit/ota_update_state.dart'; +import 'package:tesla_android/feature/home/model/github_release.dart'; +import 'package:tesla_android/feature/home/repository/github_release_repository.dart'; + +import 'ota_update_cubit_test.mocks.dart'; + +@GenerateMocks([GitHubReleaseRepository]) +void main() { + late MockGitHubReleaseRepository mockRepository; + late SharedPreferences sharedPreferences; + + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + PackageInfo.setMockInitialValues( + appName: 'Tesla Android', + packageName: 'com.teslaandroid', + version: '1.0.0', + buildNumber: '1', + buildSignature: '', + ); + }); + + setUp(() async { + mockRepository = MockGitHubReleaseRepository(); + SharedPreferences.setMockInitialValues({}); + sharedPreferences = await SharedPreferences.getInstance(); + }); + + tearDown(() async { + await sharedPreferences.clear(); + }); + + group('OTAUpdateCubit', () { + test('initial state is OTAUpdateStateInitial', () { + final cubit = OTAUpdateCubit(mockRepository, sharedPreferences); + expect(cubit.state, isA()); + cubit.close(); + }); + + blocTest( + 'checkForUpdates emits Available when newer version exists', + build: () { + when( + mockRepository.getLatestRelease(), + ).thenAnswer((_) async => const GitHubRelease(name: '2.0.0')); + return OTAUpdateCubit(mockRepository, sharedPreferences); + }, + act: (cubit) async => await cubit.checkForUpdates(), + expect: () => [isA()], + verify: (cubit) { + verify(mockRepository.getLatestRelease()).called(1); + expect(sharedPreferences.getBool('ota_update_available'), true); + }, + ); + + blocTest( + 'checkForUpdates emits NotAvailable when no newer version', + build: () { + when( + mockRepository.getLatestRelease(), + ).thenAnswer((_) async => const GitHubRelease(name: '0.1.0')); + return OTAUpdateCubit(mockRepository, sharedPreferences); + }, + act: (cubit) async => await cubit.checkForUpdates(), + expect: () => [isA()], + verify: (cubit) { + verify(mockRepository.getLatestRelease()).called(1); + expect(sharedPreferences.getBool('ota_update_available'), false); + }, + ); + + blocTest( + 'checkForUpdates uses cached result when within 6 hours', + build: () { + final now = DateTime.now().millisecondsSinceEpoch; + sharedPreferences.setInt('ota_last_checked', now); + sharedPreferences.setBool('ota_update_available', true); + sharedPreferences.setString('ota_last_version', '1.0.0'); + return OTAUpdateCubit(mockRepository, sharedPreferences); + }, + act: (cubit) async => await cubit.checkForUpdates(), + expect: () => [isA()], + verify: (cubit) { + verifyNever(mockRepository.getLatestRelease()); + }, + ); + + blocTest( + 'checkForUpdates rechecks when cache is stale (>6 hours)', + build: () { + final sixHoursAgo = + DateTime.now().millisecondsSinceEpoch - (7 * 60 * 60 * 1000); + sharedPreferences.setInt('ota_last_checked', sixHoursAgo); + sharedPreferences.setBool('ota_update_available', false); + when( + mockRepository.getLatestRelease(), + ).thenAnswer((_) async => const GitHubRelease(name: '2.0.0')); + return OTAUpdateCubit(mockRepository, sharedPreferences); + }, + act: (cubit) async => await cubit.checkForUpdates(), + expect: () => [isA()], + verify: (cubit) { + verify(mockRepository.getLatestRelease()).called(1); + }, + ); + + test('launchUpdater calls repository openUpdater', () { + when(mockRepository.openUpdater()).thenAnswer((_) async => {}); + final cubit = OTAUpdateCubit(mockRepository, sharedPreferences); + + cubit.launchUpdater(); + + verify(mockRepository.openUpdater()).called(1); + cubit.close(); + }); + + group('version comparison', () { + blocTest( + 'detects newer major version', + build: () { + when( + mockRepository.getLatestRelease(), + ).thenAnswer((_) async => const GitHubRelease(name: '2.0.0')); + return OTAUpdateCubit(mockRepository, sharedPreferences); + }, + act: (cubit) async => await cubit.checkForUpdates(), + expect: () => [isA()], + verify: (cubit) { + verify(mockRepository.getLatestRelease()).called(1); + expect(sharedPreferences.getBool('ota_update_available'), true); + expect(sharedPreferences.getString('ota_last_version'), '1.0.0'); + }, + ); + + blocTest( + 'detects newer minor version', + build: () { + when( + mockRepository.getLatestRelease(), + ).thenAnswer((_) async => const GitHubRelease(name: '1.5.0')); + return OTAUpdateCubit(mockRepository, sharedPreferences); + }, + act: (cubit) async => await cubit.checkForUpdates(), + expect: () => [isA()], + verify: (cubit) { + verify(mockRepository.getLatestRelease()).called(1); + expect(sharedPreferences.getBool('ota_update_available'), true); + }, + ); + + blocTest( + 'detects newer patch version', + build: () { + when( + mockRepository.getLatestRelease(), + ).thenAnswer((_) async => const GitHubRelease(name: '1.0.5')); + return OTAUpdateCubit(mockRepository, sharedPreferences); + }, + act: (cubit) async => await cubit.checkForUpdates(), + expect: () => [isA()], + verify: (cubit) { + verify(mockRepository.getLatestRelease()).called(1); + expect(sharedPreferences.getBool('ota_update_available'), true); + }, + ); + }); + + test('close cancels properly', () async { + final cubit = OTAUpdateCubit(mockRepository, sharedPreferences); + await cubit.close(); + expect(cubit.isClosed, true); + }); + }); +} diff --git a/test/unit/cubits/ota_update_cubit_test.mocks.dart b/test/unit/cubits/ota_update_cubit_test.mocks.dart new file mode 100644 index 0000000..da43f4e --- /dev/null +++ b/test/unit/cubits/ota_update_cubit_test.mocks.dart @@ -0,0 +1,61 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in tesla_android/test/unit/cubits/ota_update_cubit_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i4; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:tesla_android/feature/home/model/github_release.dart' as _i2; +import 'package:tesla_android/feature/home/repository/github_release_repository.dart' + as _i3; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeGitHubRelease_0 extends _i1.SmartFake implements _i2.GitHubRelease { + _FakeGitHubRelease_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +/// A class which mocks [GitHubReleaseRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockGitHubReleaseRepository extends _i1.Mock + implements _i3.GitHubReleaseRepository { + MockGitHubReleaseRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i4.Future<_i2.GitHubRelease> getLatestRelease() => + (super.noSuchMethod( + Invocation.method(#getLatestRelease, []), + returnValue: _i4.Future<_i2.GitHubRelease>.value( + _FakeGitHubRelease_0( + this, + Invocation.method(#getLatestRelease, []), + ), + ), + ) + as _i4.Future<_i2.GitHubRelease>); + + @override + _i4.Future openUpdater() => + (super.noSuchMethod( + Invocation.method(#openUpdater, []), + returnValue: _i4.Future.value(), + ) + as _i4.Future); +} diff --git a/test/unit/cubits/rear_display_configuration_cubit_test.dart b/test/unit/cubits/rear_display_configuration_cubit_test.dart new file mode 100644 index 0000000..d97b77d --- /dev/null +++ b/test/unit/cubits/rear_display_configuration_cubit_test.dart @@ -0,0 +1,178 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:tesla_android/feature/display/repository/display_repository.dart'; +import 'package:tesla_android/feature/settings/bloc/rear_display_configuration_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/rear_display_configuration_state.dart'; + +import '../../helpers/test_fixtures.dart'; +import 'rear_display_configuration_cubit_test.mocks.dart'; + +@GenerateMocks([DisplayRepository]) +void main() { + late MockDisplayRepository mockRepository; + + setUp(() { + mockRepository = MockDisplayRepository(); + }); + + group('RearDisplayConfigurationCubit', () { + test('initial state is RearDisplayConfigurationStateInitial', () { + final cubit = RearDisplayConfigurationCubit(mockRepository); + expect(cubit.state, isA()); + cubit.close(); + }); + + blocTest( + 'fetchConfiguration emits Loading then Fetched on success', + build: () { + when( + mockRepository.getDisplayState(), + ).thenAnswer((_) async => TestFixtures.defaultDisplayState); + when(mockRepository.isPrimaryDisplay()).thenAnswer((_) async => true); + return RearDisplayConfigurationCubit(mockRepository); + }, + act: (cubit) => cubit.fetchConfiguration(), + expect: () => [ + isA(), + isA().having( + (s) => s.isCurrentDisplayPrimary, + 'isCurrentDisplayPrimary', + true, + ), + ], + verify: (cubit) { + verify(mockRepository.getDisplayState()).called(1); + verify(mockRepository.isPrimaryDisplay()).called(1); + }, + ); + + blocTest( + 'fetchConfiguration emits Loading then Error on failure', + build: () { + when( + mockRepository.getDisplayState(), + ).thenThrow(Exception('Network error')); + return RearDisplayConfigurationCubit(mockRepository); + }, + act: (cubit) => cubit.fetchConfiguration(), + expect: () => [ + isA(), + isA(), + ], + ); + + blocTest( + 'setRearDisplayState updates state and repository', + build: () { + when( + mockRepository.getDisplayState(), + ).thenAnswer((_) async => TestFixtures.defaultDisplayState); + when(mockRepository.isPrimaryDisplay()).thenAnswer((_) async => true); + when( + mockRepository.updateDisplayConfiguration(any), + ).thenAnswer((_) async => {}); + return RearDisplayConfigurationCubit(mockRepository); + }, + act: (cubit) async { + cubit.fetchConfiguration(); + // Wait for fetch to complete + await Future.delayed(Duration.zero); + cubit.setRearDisplayState(true); + }, + skip: 2, // Skip loading states from fetchConfiguration + expect: () => [ + isA(), + isA().having( + (s) => s.isRearDisplayEnabled, + 'isRearDisplayEnabled', + true, + ), + ], + verify: (cubit) { + verify(mockRepository.updateDisplayConfiguration(any)).called(1); + }, + ); + + blocTest( + 'setRearDisplayPrioritization updates state and repository', + build: () { + when( + mockRepository.getDisplayState(), + ).thenAnswer((_) async => TestFixtures.defaultDisplayState); + when(mockRepository.isPrimaryDisplay()).thenAnswer((_) async => true); + when( + mockRepository.updateDisplayConfiguration(any), + ).thenAnswer((_) async => {}); + return RearDisplayConfigurationCubit(mockRepository); + }, + act: (cubit) async { + cubit.fetchConfiguration(); + await Future.delayed(Duration.zero); + cubit.setRearDisplayPrioritization(true); + }, + skip: 2, + expect: () => [ + isA(), + isA().having( + (s) => s.isRearDisplayPrioritised, + 'isRearDisplayPrioritised', + true, + ), + ], + ); + + blocTest( + 'setDisplayType calls repository and emits updated config', + build: () { + when( + mockRepository.getDisplayState(), + ).thenAnswer((_) async => TestFixtures.defaultDisplayState); + when(mockRepository.isPrimaryDisplay()).thenAnswer((_) async => false); + when(mockRepository.setDisplayType(any)).thenAnswer((_) async => {}); + return RearDisplayConfigurationCubit(mockRepository); + }, + act: (cubit) async { + cubit.fetchConfiguration(); + await Future.delayed(Duration.zero); + cubit.setDisplayType(isCurrentDisplayPrimary: false); + }, + skip: 2, + expect: () => [ + isA().having( + (s) => s.isCurrentDisplayPrimary, + 'isCurrentDisplayPrimary', + false, + ), + ], + verify: (cubit) { + verify(mockRepository.setDisplayType(false)).called(1); + }, + ); + + blocTest( + 'setter emits Error on repository failure', + build: () { + when( + mockRepository.getDisplayState(), + ).thenAnswer((_) async => TestFixtures.defaultDisplayState); + when(mockRepository.isPrimaryDisplay()).thenAnswer((_) async => true); + when( + mockRepository.updateDisplayConfiguration(any), + ).thenThrow(Exception('Network error')); + return RearDisplayConfigurationCubit(mockRepository); + }, + act: (cubit) async { + cubit.fetchConfiguration(); + await Future.delayed(Duration.zero); + cubit.setRearDisplayState(true); + }, + skip: 2, + expect: () => [ + isA(), + isA(), + ], + ); + }); +} diff --git a/test/unit/cubits/rear_display_configuration_cubit_test.mocks.dart b/test/unit/cubits/rear_display_configuration_cubit_test.mocks.dart new file mode 100644 index 0000000..cfa175c --- /dev/null +++ b/test/unit/cubits/rear_display_configuration_cubit_test.mocks.dart @@ -0,0 +1,80 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in tesla_android/test/unit/cubits/rear_display_configuration_cubit_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i4; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:tesla_android/feature/display/model/remote_display_state.dart' + as _i2; +import 'package:tesla_android/feature/display/repository/display_repository.dart' + as _i3; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeRemoteDisplayState_0 extends _i1.SmartFake + implements _i2.RemoteDisplayState { + _FakeRemoteDisplayState_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +/// A class which mocks [DisplayRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockDisplayRepository extends _i1.Mock implements _i3.DisplayRepository { + MockDisplayRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i4.Future<_i2.RemoteDisplayState> getDisplayState() => + (super.noSuchMethod( + Invocation.method(#getDisplayState, []), + returnValue: _i4.Future<_i2.RemoteDisplayState>.value( + _FakeRemoteDisplayState_0( + this, + Invocation.method(#getDisplayState, []), + ), + ), + ) + as _i4.Future<_i2.RemoteDisplayState>); + + @override + _i4.Future updateDisplayConfiguration( + _i2.RemoteDisplayState? configuration, + ) => + (super.noSuchMethod( + Invocation.method(#updateDisplayConfiguration, [configuration]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future isPrimaryDisplay() => + (super.noSuchMethod( + Invocation.method(#isPrimaryDisplay, []), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setDisplayType(bool? isPrimaryDisplay) => + (super.noSuchMethod( + Invocation.method(#setDisplayType, [isPrimaryDisplay]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); +} diff --git a/test/unit/cubits/release_notes_cubit_test.dart b/test/unit/cubits/release_notes_cubit_test.dart new file mode 100644 index 0000000..9892342 --- /dev/null +++ b/test/unit/cubits/release_notes_cubit_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:tesla_android/feature/releaseNotes/cubit/release_notes_cubit.dart'; +import 'package:tesla_android/feature/releaseNotes/cubit/release_notes_state.dart'; +import 'package:tesla_android/feature/releaseNotes/model/changelog_item.dart'; +import 'package:tesla_android/feature/releaseNotes/model/release_notes.dart'; +import 'package:tesla_android/feature/releaseNotes/model/version.dart'; +import 'package:tesla_android/feature/releaseNotes/repository/release_notes_repository.dart'; + +import 'release_notes_cubit_test.mocks.dart'; + +@GenerateMocks([ReleaseNotesRepository]) +void main() { + late MockReleaseNotesRepository mockRepository; + + final testChangelogItem = ChangelogItem( + title: 'Test Feature', + shortDescription: 'Description', + descriptionMarkdown: 'Markdown', + ); + + final testVersion = Version( + versionName: '1.0.0', + changelogItems: [testChangelogItem], + ); + + final testReleaseNotes = ReleaseNotes(versions: [testVersion]); + + setUp(() { + mockRepository = MockReleaseNotesRepository(); + }); + + group('ReleaseNotesCubit', () { + test('initial state is ReleaseNotesStateInitial', () { + final cubit = ReleaseNotesCubit(mockRepository); + expect(cubit.state, isA()); + cubit.close(); + }); + + blocTest( + 'loadReleaseNotes emits Loading then Loaded on success', + build: () { + when( + mockRepository.getReleaseNotes(), + ).thenAnswer((_) async => testReleaseNotes); + return ReleaseNotesCubit(mockRepository); + }, + act: (cubit) => cubit.loadReleaseNotes(), + expect: () => [ + isA(), + isA().having( + (s) => s.releaseNotes, + 'releaseNotes', + testReleaseNotes, + ), + ], + verify: (cubit) { + verify(mockRepository.getReleaseNotes()).called(1); + }, + ); + + blocTest( + 'loadReleaseNotes emits Loading then Unavailable on failure', + build: () { + when( + mockRepository.getReleaseNotes(), + ).thenThrow(Exception('Network error')); + return ReleaseNotesCubit(mockRepository); + }, + act: (cubit) => cubit.loadReleaseNotes(), + expect: () => [ + isA(), + isA(), + ], + ); + + blocTest( + 'updateSelection updates state when loaded', + build: () { + when( + mockRepository.getReleaseNotes(), + ).thenAnswer((_) async => testReleaseNotes); + return ReleaseNotesCubit(mockRepository); + }, + seed: () => ReleaseNotesStateLoaded(releaseNotes: testReleaseNotes), + act: (cubit) => cubit.updateSelection( + version: testVersion, + changelogItem: testChangelogItem, + ), + expect: () => [ + isA() + .having((s) => s.selectedVersion, 'selectedVersion', testVersion) + .having( + (s) => s.selectedChangelogItem, + 'selectedChangelogItem', + testChangelogItem, + ), + ], + ); + + blocTest( + 'updateSelection does nothing when not loaded', + build: () => ReleaseNotesCubit(mockRepository), + seed: () => ReleaseNotesStateInitial(), + act: (cubit) => cubit.updateSelection( + version: testVersion, + changelogItem: testChangelogItem, + ), + expect: () => [], + ); + }); +} diff --git a/test/unit/cubits/release_notes_cubit_test.mocks.dart b/test/unit/cubits/release_notes_cubit_test.mocks.dart new file mode 100644 index 0000000..e070cee --- /dev/null +++ b/test/unit/cubits/release_notes_cubit_test.mocks.dart @@ -0,0 +1,54 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in tesla_android/test/unit/cubits/release_notes_cubit_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i4; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:tesla_android/feature/releaseNotes/model/release_notes.dart' + as _i2; +import 'package:tesla_android/feature/releaseNotes/repository/release_notes_repository.dart' + as _i3; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeReleaseNotes_0 extends _i1.SmartFake implements _i2.ReleaseNotes { + _FakeReleaseNotes_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +/// A class which mocks [ReleaseNotesRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockReleaseNotesRepository extends _i1.Mock + implements _i3.ReleaseNotesRepository { + MockReleaseNotesRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i4.Future<_i2.ReleaseNotes> getReleaseNotes() => + (super.noSuchMethod( + Invocation.method(#getReleaseNotes, []), + returnValue: _i4.Future<_i2.ReleaseNotes>.value( + _FakeReleaseNotes_0( + this, + Invocation.method(#getReleaseNotes, []), + ), + ), + ) + as _i4.Future<_i2.ReleaseNotes>); +} diff --git a/test/unit/cubits/system_configuration_cubit_test.dart b/test/unit/cubits/system_configuration_cubit_test.dart new file mode 100644 index 0000000..8a54223 --- /dev/null +++ b/test/unit/cubits/system_configuration_cubit_test.dart @@ -0,0 +1,184 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:tesla_android/feature/settings/bloc/system_configuration_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/system_configuration_state.dart'; +import 'package:tesla_android/feature/settings/model/softap_band_type.dart'; +import 'package:tesla_android/feature/settings/repository/system_configuration_repository.dart'; + +import '../../helpers/test_fixtures.dart'; +import 'system_configuration_cubit_test.mocks.dart'; + +@GenerateMocks([SystemConfigurationRepository]) +void main() { + late MockSystemConfigurationRepository mockRepository; + + setUp(() { + mockRepository = MockSystemConfigurationRepository(); + }); + + group('SystemConfigurationCubit', () { + test('initial state is SystemConfigurationStateInitial', () { + final cubit = SystemConfigurationCubit(mockRepository); + expect(cubit.state, isA()); + cubit.close(); + }); + + blocTest( + 'fetchConfiguration emits Loading then SettingsFetched on success', + build: () { + when( + mockRepository.getConfiguration(), + ).thenAnswer((_) async => TestFixtures.systemConfiguration); + return SystemConfigurationCubit(mockRepository); + }, + act: (cubit) async => await cubit.fetchConfiguration(), + expect: () => [ + isA(), + isA(), + ], + verify: (cubit) { + verify(mockRepository.getConfiguration()).called(1); + }, + ); + + blocTest( + 'fetchConfiguration emits Error on failure', + build: () { + when( + mockRepository.getConfiguration(), + ).thenThrow(Exception('Network error')); + return SystemConfigurationCubit(mockRepository); + }, + act: (cubit) async => await cubit.fetchConfiguration(), + expect: () => [ + isA(), + isA(), + ], + ); + + group('Configuration update methods', () { + // Parametrized test cases for configuration updates + final testCases = [ + ( + name: 'updateSoftApBand', + action: (SystemConfigurationCubit cubit) => + cubit.updateSoftApBand(SoftApBandType.band5GHz36), + verify: (SystemConfigurationStateSettingsModified state) { + expect(state.newBandType, SoftApBandType.band5GHz36); + }, + ), + ( + name: 'updateSoftApState', + action: (SystemConfigurationCubit cubit) => + cubit.updateSoftApState(false), + verify: (SystemConfigurationStateSettingsModified state) { + expect(state.isSoftApEnabled, false); + }, + ), + ( + name: 'updateOfflineModeState', + action: (SystemConfigurationCubit cubit) => + cubit.updateOfflineModeState(true), + verify: (SystemConfigurationStateSettingsModified state) { + expect(state.isOfflineModeEnabled, true); + }, + ), + ( + name: 'updateOfflineModeTelemetryState', + action: (SystemConfigurationCubit cubit) => + cubit.updateOfflineModeTelemetryState(false), + verify: (SystemConfigurationStateSettingsModified state) { + expect(state.isOfflineModeTelemetryEnabled, false); + }, + ), + ( + name: 'updateOfflineModeTeslaFirmwareDownloadsState', + action: (SystemConfigurationCubit cubit) => + cubit.updateOfflineModeTeslaFirmwareDownloadsState(true), + verify: (SystemConfigurationStateSettingsModified state) { + expect(state.isOfflineModeTeslaFirmwareDownloadsEnabled, true); + }, + ), + ]; + + for (final testCase in testCases) { + blocTest( + '${testCase.name} updates state correctly', + build: () => SystemConfigurationCubit(mockRepository), + seed: () => SystemConfigurationStateSettingsFetched( + currentConfiguration: TestFixtures.systemConfiguration, + ), + act: testCase.action, + expect: () => [isA()], + verify: (cubit) { + final state = + cubit.state as SystemConfigurationStateSettingsModified; + testCase.verify(state); + }, + ); + } + }); + + blocTest( + 'applySystemConfiguration calls repository methods', + build: () { + when(mockRepository.setSoftApBand(any)).thenAnswer((_) async => {}); + when( + mockRepository.setSoftApChannelWidth(any), + ).thenAnswer((_) async => {}); + when(mockRepository.setSoftApChannel(any)).thenAnswer((_) async => {}); + when(mockRepository.setSoftApState(any)).thenAnswer((_) async => {}); + when( + mockRepository.setOfflineModeState(any), + ).thenAnswer((_) async => {}); + when( + mockRepository.setOfflineModeTelemetryState(any), + ).thenAnswer((_) async => {}); + when( + mockRepository.setOfflineModeTeslaFirmwareDownloads(any), + ).thenAnswer((_) async => {}); + return SystemConfigurationCubit(mockRepository); + }, + seed: () => + SystemConfigurationStateSettingsModified.fromCurrentConfiguration( + currentConfiguration: TestFixtures.systemConfiguration, + newBandType: SoftApBandType.band5GHz36, + isSoftApEnabled: true, + isOfflineModeEnabled: false, + ), + act: (cubit) => cubit.applySystemConfiguration(), + verify: (cubit) { + verify(mockRepository.setSoftApBand(any)).called(1); + verify(mockRepository.setSoftApChannelWidth(any)).called(1); + verify(mockRepository.setSoftApChannel(any)).called(1); + verify(mockRepository.setSoftApState(any)).called(1); + verify(mockRepository.setOfflineModeState(any)).called(1); + }, + ); + + blocTest( + 'applySystemConfiguration emits Error on failure', + build: () { + when( + mockRepository.setSoftApBand(any), + ).thenThrow(Exception('Network error')); + return SystemConfigurationCubit(mockRepository); + }, + seed: () => + SystemConfigurationStateSettingsModified.fromCurrentConfiguration( + currentConfiguration: TestFixtures.systemConfiguration, + newBandType: SoftApBandType.band5GHz36, + ), + act: (cubit) => cubit.applySystemConfiguration(), + expect: () => [isA()], + ); + + test('close cancels properly', () async { + final cubit = SystemConfigurationCubit(mockRepository); + await cubit.close(); + expect(cubit.isClosed, true); + }); + }); +} diff --git a/test/unit/cubits/system_configuration_cubit_test.mocks.dart b/test/unit/cubits/system_configuration_cubit_test.mocks.dart new file mode 100644 index 0000000..e247938 --- /dev/null +++ b/test/unit/cubits/system_configuration_cubit_test.mocks.dart @@ -0,0 +1,141 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in tesla_android/test/unit/cubits/system_configuration_cubit_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i4; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:tesla_android/feature/settings/model/system_configuration_response_body.dart' + as _i2; +import 'package:tesla_android/feature/settings/repository/system_configuration_repository.dart' + as _i3; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeSystemConfigurationResponseBody_0 extends _i1.SmartFake + implements _i2.SystemConfigurationResponseBody { + _FakeSystemConfigurationResponseBody_0( + Object parent, + Invocation parentInvocation, + ) : super(parent, parentInvocation); +} + +/// A class which mocks [SystemConfigurationRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockSystemConfigurationRepository extends _i1.Mock + implements _i3.SystemConfigurationRepository { + MockSystemConfigurationRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i4.Future<_i2.SystemConfigurationResponseBody> getConfiguration() => + (super.noSuchMethod( + Invocation.method(#getConfiguration, []), + returnValue: _i4.Future<_i2.SystemConfigurationResponseBody>.value( + _FakeSystemConfigurationResponseBody_0( + this, + Invocation.method(#getConfiguration, []), + ), + ), + ) + as _i4.Future<_i2.SystemConfigurationResponseBody>); + + @override + _i4.Future setSoftApBand(int? band) => + (super.noSuchMethod( + Invocation.method(#setSoftApBand, [band]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setSoftApChannel(int? channel) => + (super.noSuchMethod( + Invocation.method(#setSoftApChannel, [channel]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setSoftApChannelWidth(int? channelWidth) => + (super.noSuchMethod( + Invocation.method(#setSoftApChannelWidth, [channelWidth]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setSoftApState(int? isEnabledFlag) => + (super.noSuchMethod( + Invocation.method(#setSoftApState, [isEnabledFlag]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setOfflineModeState(int? isEnabledFlag) => + (super.noSuchMethod( + Invocation.method(#setOfflineModeState, [isEnabledFlag]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setOfflineModeTelemetryState(int? isEnabledFlag) => + (super.noSuchMethod( + Invocation.method(#setOfflineModeTelemetryState, [isEnabledFlag]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setOfflineModeTeslaFirmwareDownloads( + int? isEnabledFlag, + ) => + (super.noSuchMethod( + Invocation.method(#setOfflineModeTeslaFirmwareDownloads, [ + isEnabledFlag, + ]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setBrowserAudioState(int? isEnabledFlag) => + (super.noSuchMethod( + Invocation.method(#setBrowserAudioState, [isEnabledFlag]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setBrowserAudioVolume(int? volume) => + (super.noSuchMethod( + Invocation.method(#setBrowserAudioVolume, [volume]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setGPSState(int? isEnabled) => + (super.noSuchMethod( + Invocation.method(#setGPSState, [isEnabled]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); +} diff --git a/test/unit/cubits/touchscreen_cubit_test.dart b/test/unit/cubits/touchscreen_cubit_test.dart new file mode 100644 index 0000000..93d5e65 --- /dev/null +++ b/test/unit/cubits/touchscreen_cubit_test.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/feature/touchscreen/cubit/touchscreen_cubit.dart'; +import 'package:tesla_android/feature/touchscreen/service/message_sender.dart'; + +import 'touchscreen_cubit_test.mocks.dart'; + +@GenerateMocks([MessageSender]) +void main() { + group('TouchscreenCubit', () { + late TouchscreenCubit cubit; + late MockMessageSender mockMessageSender; + + setUp(() { + mockMessageSender = MockMessageSender(); + cubit = TouchscreenCubit(mockMessageSender); + }); + + tearDown(() { + cubit.close(); + }); + + test('initial state is false', () { + expect(cubit.state, false); + }); + + test('handlePointerDownEvent sends correct command', () { + final event = PointerDownEvent( + pointer: 1, + position: const Offset(100, 100), + ); + const constraints = BoxConstraints(maxWidth: 1920, maxHeight: 1080); + const touchscreenSize = Size(1920, 1080); + + cubit.handlePointerDownEvent(event, constraints, touchscreenSize); + + final captured = verify( + mockMessageSender.postMessage(captureAny, '*'), + ).captured; + expect(captured.length, 1); + final message = captured.first as String; + + expect(message, contains('T 1')); + expect(message, contains('X 100')); + expect(message, contains('Y 100')); + expect(message, contains('S 0')); + }); + + test('handlePointerMoveEvent sends correct command', () { + // First, put a pointer down to assign a slot + final downEvent = PointerDownEvent( + pointer: 1, + position: const Offset(100, 100), + ); + const constraints = BoxConstraints(maxWidth: 1920, maxHeight: 1080); + const touchscreenSize = Size(1920, 1080); + + cubit.handlePointerDownEvent(downEvent, constraints, touchscreenSize); + clearInteractions(mockMessageSender); + + final moveEvent = PointerMoveEvent( + pointer: 1, + position: const Offset(200, 200), + ); + + cubit.handlePointerMoveEvent(moveEvent, constraints, touchscreenSize); + + final captured = verify( + mockMessageSender.postMessage(captureAny, '*'), + ).captured; + expect(captured.length, 1); + final message = captured.first as String; + + expect(message, contains('X 200')); + expect(message, contains('Y 200')); + expect(message, contains('S 0')); + expect(message, isNot(contains('T '))); + }); + + test('handlePointerUpEvent sends correct command', () { + // Down first + final downEvent = PointerDownEvent( + pointer: 1, + position: const Offset(100, 100), + ); + const constraints = BoxConstraints(maxWidth: 1920, maxHeight: 1080); + const touchscreenSize = Size(1920, 1080); + + cubit.handlePointerDownEvent(downEvent, constraints, touchscreenSize); + clearInteractions(mockMessageSender); + + final upEvent = PointerUpEvent( + pointer: 1, + position: const Offset(100, 100), + ); + + cubit.handlePointerUpEvent(upEvent, constraints); + + final captured = verify( + mockMessageSender.postMessage(captureAny, '*'), + ).captured; + expect(captured.length, 1); + final message = captured.first as String; + + expect(message, contains('T -1')); + expect(message, contains('S 0')); + }); + + test('resetTouchScreen sends reset commands', () { + cubit.resetTouchScreen(); + + final captured = verify( + mockMessageSender.postMessage(captureAny, '*'), + ).captured; + expect(captured.length, 1); + final message = captured.first as String; + + expect(message, contains('T -1')); + expect(message, contains('S 0')); + }); + }); +} diff --git a/test/unit/cubits/touchscreen_cubit_test.mocks.dart b/test/unit/cubits/touchscreen_cubit_test.mocks.dart new file mode 100644 index 0000000..9e98589 --- /dev/null +++ b/test/unit/cubits/touchscreen_cubit_test.mocks.dart @@ -0,0 +1,37 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in tesla_android/test/unit/cubits/touchscreen_cubit_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'package:mockito/mockito.dart' as _i1; +import 'package:tesla_android/feature/touchscreen/service/message_sender.dart' + as _i2; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +/// A class which mocks [MessageSender]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockMessageSender extends _i1.Mock implements _i2.MessageSender { + MockMessageSender() { + _i1.throwOnMissingStub(this); + } + + @override + void postMessage(String? message, String? targetOrigin) => super.noSuchMethod( + Invocation.method(#postMessage, [message, targetOrigin]), + returnValueForMissingStub: null, + ); +} diff --git a/test/unit/factories/factory_test.dart b/test/unit/factories/factory_test.dart new file mode 100644 index 0000000..5dff19c --- /dev/null +++ b/test/unit/factories/factory_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tesla_android/common/service/audio_service.dart'; +import 'package:tesla_android/common/service/audio_service_factory.dart'; +import 'package:tesla_android/common/service/window_service.dart'; +import 'package:tesla_android/common/service/window_service_factory.dart'; +import 'package:tesla_android/feature/touchscreen/service/message_sender.dart'; +import 'package:tesla_android/feature/touchscreen/service/message_sender_factory.dart'; +import 'package:tesla_android/common/di/flavor_factory.dart'; +import 'package:flavor/flavor.dart'; + +void main() { + group('Factory Tests', () { + group('AudioServiceFactory', () { + test('creates AudioService instance', () { + final service = AudioServiceFactory.create(); + expect(service, isNotNull); + expect(service, isA()); + }); + + test('creates new instance each time', () { + final service1 = AudioServiceFactory.create(); + final service2 = AudioServiceFactory.create(); + expect(identical(service1, service2), isFalse); + }); + + test('created instance has expected interface', () { + final service = AudioServiceFactory.create(); + // Verify interface methods exist (stubs won't throw) + expect(() => service.getAudioState(), returnsNormally); + expect(() => service.stopAudio(), returnsNormally); + }); + }); + + group('WindowServiceFactory', () { + test('creates WindowService instance', () { + final service = WindowServiceFactory.create(); + expect(service, isNotNull); + expect(service, isA()); + }); + + test('creates new instance each time', () { + final service1 = WindowServiceFactory.create(); + final service2 = WindowServiceFactory.create(); + expect(identical(service1, service2), isFalse); + }); + + test('created instance has expected interface', () { + final service = WindowServiceFactory.create(); + // Verify interface method exists (stub won't throw) + expect(() => service.reload(), returnsNormally); + }); + }); + + group('MessageSenderFactory', () { + test('creates MessageSender instance', () { + final sender = MessageSenderFactory.create(); + expect(sender, isNotNull); + expect(sender, isA()); + }); + + test('creates new instance each time', () { + final sender1 = MessageSenderFactory.create(); + final sender2 = MessageSenderFactory.create(); + expect(identical(sender1, sender2), isFalse); + }); + + test('created instance has expected interface', () { + final sender = MessageSenderFactory.create(); + // Verify interface method exists (stub won't throw) + expect(() => sender.postMessage('test', '*'), returnsNormally); + }); + }); + + group('FlavorFactory', () { + test('creates Flavor instance', () { + final flavor = FlavorFactory.create(); + expect(flavor, isNotNull); + expect(flavor, isA()); + }); + + test('creates consistent configuration', () { + final flavor1 = FlavorFactory.create(); + final flavor2 = FlavorFactory.create(); + // Both should provide the same configuration values + // even if they are different instances + expect(flavor1.runtimeType, equals(flavor2.runtimeType)); + }); + }); + }); +} diff --git a/test/unit/models/device_info_test.dart b/test/unit/models/device_info_test.dart new file mode 100644 index 0000000..8df9b2e --- /dev/null +++ b/test/unit/models/device_info_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tesla_android/feature/settings/model/device_info.dart'; +import '../../helpers/test_fixtures.dart'; + +void main() { + group('DeviceInfo', () { + test('creates instance with all fields', () { + final info = TestFixtures.rpi4DeviceInfo; + + expect(info.cpuTemperature, 45); + expect(info.serialNumber, "RPI4TEST001"); + expect(info.deviceModel, "rpi4"); + expect(info.isCarPlayDetected, 0); + expect(info.isModemDetected, 1); + expect(info.releaseType, "stable"); + expect(info.isGPSEnabled, 1); + }); + + test('fromJson deserializes correctly', () { + final info = DeviceInfo.fromJson(TestFixtures.deviceInfoJson); + + expect(info.cpuTemperature, 45); + expect(info.serialNumber, "TEST001"); + expect(info.deviceModel, "rpi4"); + expect(info.isModemDetected, 1); + expect(info.isCarPlayDetected, 0); + expect(info.releaseType, "stable"); + expect(info.isGPSEnabled, 1); + }); + + test('toJson serializes correctly', () { + final info = TestFixtures.rpi4DeviceInfo; + final json = info.toJson(); + + expect(json['cpu_temperature'], 45); + expect(json['serial_number'], "RPI4TEST001"); + expect(json['device_model'], "rpi4"); + expect(json['is_modem_detected'], 1); + expect(json['is_carplay_detected'], 0); + expect(json['release_type'], "stable"); + expect(json['is_gps_enabled'], 1); + }); + + test('equality works correctly', () { + final info1 = TestFixtures.rpi4DeviceInfo; + final info2 = DeviceInfo( + cpuTemperature: 45, + serialNumber: "RPI4TEST001", + deviceModel: "rpi4", + isCarPlayDetected: 0, + isModemDetected: 1, + releaseType: "stable", + otaUrl: "https://example.com/ota", + isGPSEnabled: 1, + ); + + expect(info1, equals(info2)); + }); + + test('handles default values for missing JSON fields', () { + final Map json = { + // Minimal JSON - testing defaults + }; + + final info = DeviceInfo.fromJson(json); + + expect(info.cpuTemperature, 0); // default + expect(info.serialNumber, "undefined"); // default + expect(info.deviceModel, "undefined"); // default + expect(info.isModemDetected, 0); // default + expect(info.isCarPlayDetected, 0); // default + expect(info.releaseType, "undefined"); // default + expect(info.isGPSEnabled, 0); // default + }); + }); + + group('DeviceNameExtension', () { + test('returns "Raspberry Pi 4" for rpi4 model', () { + final info = DeviceInfo( + cpuTemperature: 45, + serialNumber: "TEST", + deviceModel: "rpi4", + isCarPlayDetected: 0, + isModemDetected: 0, + releaseType: "stable", + otaUrl: "", + isGPSEnabled: 0, + ); + + expect(info.deviceName, "Raspberry Pi 4"); + }); + + test('returns "Compute Module 4" for cm4 model', () { + final info = TestFixtures.cm4DeviceInfo; + + expect(info.deviceName, "Compute Module 4"); + }); + + test('returns "UNOFFICIAL" for unknown model', () { + final info = DeviceInfo( + cpuTemperature: 45, + serialNumber: "TEST", + deviceModel: "unknown_device", + isCarPlayDetected: 0, + isModemDetected: 0, + releaseType: "stable", + otaUrl: "", + isGPSEnabled: 0, + ); + + expect(info.deviceName, "UNOFFICIAL unknown_device"); + }); + }); +} diff --git a/test/unit/models/remote_display_state_test.dart b/test/unit/models/remote_display_state_test.dart new file mode 100644 index 0000000..761f3d6 --- /dev/null +++ b/test/unit/models/remote_display_state_test.dart @@ -0,0 +1,197 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tesla_android/feature/display/model/remote_display_state.dart'; +import '../../helpers/test_fixtures.dart'; + +void main() { + group('RemoteDisplayState', () { + test('creates instance with required fields', () { + final state = TestFixtures.defaultDisplayState; + + expect(state.width, 1920); + expect(state.height, 1080); + expect(state.density, 200); + expect(state.resolutionPreset, DisplayResolutionModePreset.res832p); + expect(state.renderer, DisplayRendererType.h264WebCodecs); + expect(state.isH264, 1); + }); + + test('fromJson deserializes correctly', () { + final state = RemoteDisplayState.fromJson(TestFixtures.displayStateJson); + + expect(state.width, 1920); + expect(state.height, 1080); + expect(state.density, 200); + expect(state.resolutionPreset, DisplayResolutionModePreset.res832p); + expect(state.renderer, DisplayRendererType.h264WebCodecs); + expect(state.refreshRate, DisplayRefreshRatePreset.refresh30hz); + expect(state.quality, DisplayQualityPreset.quality90); + }); + + test('toJson serializes correctly', () { + final state = TestFixtures.defaultDisplayState; + final json = state.toJson(); + + expect(json['width'], 1920); + expect(json['height'], 1080); + expect(json['density'], 200); + expect(json['resolutionPreset'], 0); + expect(json['renderer'], 1); + expect(json['isH264'], 1); + expect(json['refreshRate'], 30); + expect(json['quality'], 90); + }); + + test('equality works correctly', () { + final state1 = TestFixtures.defaultDisplayState; + final state2 = RemoteDisplayState( + width: 1920, + height: 1080, + density: 200, + resolutionPreset: DisplayResolutionModePreset.res832p, + renderer: DisplayRendererType.h264WebCodecs, + isResponsive: 1, + isH264: 1, + refreshRate: DisplayRefreshRatePreset.refresh30hz, + quality: DisplayQualityPreset.quality90, + isRearDisplayEnabled: 0, + isRearDisplayPrioritised: 0, + isHeadless: 0, + ); + + expect(state1, equals(state2)); + }); + + test('copyWith creates new instance with updated fields', () { + final original = TestFixtures.defaultDisplayState; + final updated = original.copyWith(width: 1280, height: 720); + + expect(updated.width, 1280); + expect(updated.height, 720); + expect(updated.density, original.density); // unchanged + expect(updated.renderer, original.renderer); // unchanged + }); + + test('updateResolution changes resolution preset and density', () { + final original = TestFixtures.defaultDisplayState; + final updated = original.updateResolution( + newPreset: DisplayResolutionModePreset.res720p, + ); + + expect(updated.resolutionPreset, DisplayResolutionModePreset.res720p); + expect(updated.density, 175); // h264 density for 720p + expect(updated.width, original.width); // unchanged + expect(updated.height, original.height); // unchanged + }); + + test('updateRenderer switches to MJPEG', () { + final original = TestFixtures.defaultDisplayState; + final updated = original.updateRenderer( + newType: DisplayRendererType.mjpeg, + ); + + expect(updated.renderer, DisplayRendererType.mjpeg); + expect(updated.isH264, 0); + }); + + test('updateRenderer switches to h264', () { + final original = TestFixtures.mjpegDisplayState; + final updated = original.updateRenderer( + newType: DisplayRendererType.h264WebCodecs, + ); + + expect(updated.renderer, DisplayRendererType.h264WebCodecs); + expect(updated.isH264, 1); + }); + + test('updateQuality changes quality preset', () { + final original = TestFixtures.defaultDisplayState; + final updated = original.updateQuality( + newQuality: DisplayQualityPreset.quality70, + ); + + expect(updated.quality, DisplayQualityPreset.quality70); + }); + + test('updateRefreshRate changes refresh rate preset', () { + final original = TestFixtures.defaultDisplayState; + final updated = original.updateRefreshRate( + newRefreshRate: DisplayRefreshRatePreset.refresh60hz, + ); + + expect(updated.refreshRate, DisplayRefreshRatePreset.refresh60hz); + }); + }); + + group('DisplayResolutionModePreset', () { + test('maxHeight returns correct values', () { + expect(DisplayResolutionModePreset.res832p.maxHeight(), 832); + expect(DisplayResolutionModePreset.res720p.maxHeight(), 720); + expect(DisplayResolutionModePreset.res640p.maxHeight(), 640); + expect(DisplayResolutionModePreset.res544p.maxHeight(), 544); + expect(DisplayResolutionModePreset.res480p.maxHeight(), 480); + }); + + test('density returns correct values for h264', () { + expect(DisplayResolutionModePreset.res832p.density(isH264: true), 200); + expect(DisplayResolutionModePreset.res720p.density(isH264: true), 175); + }); + + test('density returns correct values for MJPEG', () { + expect(DisplayResolutionModePreset.res832p.density(isH264: false), 200); + expect(DisplayResolutionModePreset.res720p.density(isH264: false), 175); + expect(DisplayResolutionModePreset.res640p.density(isH264: false), 155); + expect(DisplayResolutionModePreset.res544p.density(isH264: false), 130); + expect(DisplayResolutionModePreset.res480p.density(isH264: false), 115); + }); + + test('name returns correct strings', () { + expect(DisplayResolutionModePreset.res832p.name(), "832p"); + expect(DisplayResolutionModePreset.res720p.name(), "720p"); + expect(DisplayResolutionModePreset.res640p.name(), "640p"); + expect(DisplayResolutionModePreset.res544p.name(), "544p"); + expect(DisplayResolutionModePreset.res480p.name(), "480p"); + }); + }); + + group('DisplayRendererType', () { + test('name returns correct strings', () { + expect(DisplayRendererType.mjpeg.name(), "Motion JPEG"); + expect(DisplayRendererType.h264WebCodecs.name(), "h264 (WebCodecs)"); + expect(DisplayRendererType.h264Brodway.name(), "h264 (legacy)"); + }); + + test('resourcePath returns correct paths', () { + expect(DisplayRendererType.mjpeg.resourcePath(), "mjpeg"); + expect(DisplayRendererType.h264WebCodecs.resourcePath(), "h264WebCodecs"); + expect(DisplayRendererType.h264Brodway.resourcePath(), "h264Brodway"); + }); + + test('binaryType returns correct types', () { + expect(DisplayRendererType.mjpeg.binaryType(), "blob"); + expect(DisplayRendererType.h264WebCodecs.binaryType(), "arraybuffer"); + expect(DisplayRendererType.h264Brodway.binaryType(), "arraybuffer"); + }); + }); + + group('DisplayQualityPreset', () { + test('value returns correct integers', () { + expect(DisplayQualityPreset.quality40.value(), 40); + expect(DisplayQualityPreset.quality50.value(), 50); + expect(DisplayQualityPreset.quality90.value(), 90); + }); + }); + + group('DisplayRefreshRatePreset', () { + test('value returns correct integers', () { + expect(DisplayRefreshRatePreset.refresh30hz.value(), 30); + expect(DisplayRefreshRatePreset.refresh45hz.value(), 45); + expect(DisplayRefreshRatePreset.refresh60hz.value(), 60); + }); + + test('name returns correct strings', () { + expect(DisplayRefreshRatePreset.refresh30hz.name(), "30 Hz"); + expect(DisplayRefreshRatePreset.refresh45hz.name(), "45 Hz"); + expect(DisplayRefreshRatePreset.refresh60hz.name(), "60 Hz"); + }); + }); +} diff --git a/test/unit/models/serialization_test.dart b/test/unit/models/serialization_test.dart new file mode 100644 index 0000000..9cc441f --- /dev/null +++ b/test/unit/models/serialization_test.dart @@ -0,0 +1,170 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tesla_android/feature/home/model/github_release.dart'; +import 'package:tesla_android/feature/releaseNotes/model/changelog_item.dart'; +import 'package:tesla_android/feature/releaseNotes/model/release_notes.dart'; +import 'package:tesla_android/feature/releaseNotes/model/version.dart'; +import 'package:tesla_android/feature/settings/model/system_configuration_response_body.dart'; + +void main() { + group('Model Serialization Tests', () { + group('GitHubRelease', () { + test('fromJson creates correct instance', () { + final json = {'name': 'v1.0.0'}; + final release = GitHubRelease.fromJson(json); + expect(release.name, 'v1.0.0'); + }); + + test('toJson creates correct map', () { + const release = GitHubRelease(name: 'v1.0.0'); + expect(release.toJson(), {'name': 'v1.0.0'}); + }); + }); + + group('ChangelogItem', () { + test('fromJson creates correct instance', () { + final json = { + 'title': 'Title', + 'shortDescription': 'Short', + 'descriptionMarkdown': 'Markdown', + }; + final item = ChangelogItem.fromJson(json); + expect(item.title, 'Title'); + expect(item.shortDescription, 'Short'); + expect(item.descriptionMarkdown, 'Markdown'); + }); + + test('toJson creates correct map', () { + const item = ChangelogItem( + title: 'Title', + shortDescription: 'Short', + descriptionMarkdown: 'Markdown', + ); + expect(item.toJson(), { + 'title': 'Title', + 'shortDescription': 'Short', + 'descriptionMarkdown': 'Markdown', + }); + }); + }); + + group('Version', () { + test('fromJson creates correct instance', () { + final json = { + 'versionName': '1.0.0', + 'changelogItems': [ + { + 'title': 'Title', + 'shortDescription': 'Short', + 'descriptionMarkdown': 'Markdown', + }, + ], + }; + final version = Version.fromJson(json); + expect(version.versionName, '1.0.0'); + expect(version.changelogItems.length, 1); + expect(version.changelogItems.first.title, 'Title'); + }); + + test('toJson creates correct map', () { + const version = Version( + versionName: '1.0.0', + changelogItems: [ + ChangelogItem( + title: 'Title', + shortDescription: 'Short', + descriptionMarkdown: 'Markdown', + ), + ], + ); + final json = version.toJson(); + expect(json['versionName'], '1.0.0'); + expect((json['changelogItems'] as List).length, 1); + }); + }); + + group('ReleaseNotes', () { + test('fromJson creates correct instance', () { + final json = { + 'versions': [ + {'versionName': '1.0.0', 'changelogItems': []}, + ], + }; + final notes = ReleaseNotes.fromJson(json); + expect(notes.versions.length, 1); + expect(notes.versions.first.versionName, '1.0.0'); + }); + + test('toJson creates correct map', () { + const notes = ReleaseNotes( + versions: [Version(versionName: '1.0.0', changelogItems: [])], + ); + final json = notes.toJson(); + expect((json['versions'] as List).length, 1); + }); + }); + + group('SystemConfigurationResponseBody', () { + test('fromJson creates correct instance', () { + final json = { + 'persist.tesla-android.softap.band_type': 1, + 'persist.tesla-android.softap.channel': 6, + 'persist.tesla-android.softap.channel_width': 2, + 'persist.tesla-android.softap.is_enabled': 1, + 'persist.tesla-android.offline-mode.is_enabled': 0, + 'persist.tesla-android.offline-mode.telemetry.is_enabled': 1, + 'persist.tesla-android.offline-mode.tesla-firmware-downloads': 0, + 'persist.tesla-android.browser_audio.is_enabled': 1, + 'persist.tesla-android.browser_audio.volume': 100, + 'persist.tesla-android.gps.is_active': 1, + }; + final config = SystemConfigurationResponseBody.fromJson(json); + expect(config.bandType, 1); + expect(config.channel, 6); + expect(config.channelWidth, 2); + expect(config.isEnabledFlag, 1); + expect(config.isOfflineModeEnabledFlag, 0); + expect(config.isOfflineModeTelemetryEnabledFlag, 1); + expect(config.isOfflineModeTeslaFirmwareDownloadsEnabledFlag, 0); + expect(config.browserAudioIsEnabled, 1); + expect(config.browserAudioVolume, 100); + expect(config.isGPSEnabled, 1); + }); + + test('toJson creates correct map', () { + final config = SystemConfigurationResponseBody( + bandType: 1, + channel: 6, + channelWidth: 2, + isEnabledFlag: 1, + isOfflineModeEnabledFlag: 0, + isOfflineModeTelemetryEnabledFlag: 1, + isOfflineModeTeslaFirmwareDownloadsEnabledFlag: 0, + browserAudioIsEnabled: 1, + browserAudioVolume: 100, + isGPSEnabled: 1, + ); + final json = config.toJson(); + expect(json['persist.tesla-android.softap.band_type'], 1); + expect(json['persist.tesla-android.softap.channel'], 6); + }); + + test('copyWith creates new instance with updated values', () { + final config = SystemConfigurationResponseBody( + bandType: 1, + channel: 6, + channelWidth: 2, + isEnabledFlag: 1, + isOfflineModeEnabledFlag: 0, + isOfflineModeTelemetryEnabledFlag: 1, + isOfflineModeTeslaFirmwareDownloadsEnabledFlag: 0, + browserAudioIsEnabled: 1, + browserAudioVolume: 100, + isGPSEnabled: 1, + ); + final newConfig = config.copyWith(bandType: 2); + expect(newConfig.bandType, 2); + expect(newConfig.channel, 6); // Unchanged + }); + }); + }); +} diff --git a/test/unit/models/softap_band_type_test.dart b/test/unit/models/softap_band_type_test.dart new file mode 100644 index 0000000..fda57b8 --- /dev/null +++ b/test/unit/models/softap_band_type_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tesla_android/feature/settings/model/softap_band_type.dart'; + +void main() { + group('SoftApBandType', () { + test('matchBandTypeFromConfig returns correct type for 2.4GHz', () { + final result = SoftApBandType.matchBandTypeFromConfig( + band: 1, + channel: 6, + channelWidth: 2, + ); + expect(result, SoftApBandType.band2_4GHz); + }); + + test( + 'matchBandTypeFromConfig returns correct type for 5GHz channel 36', + () { + final result = SoftApBandType.matchBandTypeFromConfig( + band: 2, + channel: 36, + channelWidth: 3, + ); + expect(result, SoftApBandType.band5GHz36); + }, + ); + + test( + 'matchBandTypeFromConfig returns correct type for 5GHz channel 44', + () { + final result = SoftApBandType.matchBandTypeFromConfig( + band: 2, + channel: 44, + channelWidth: 3, + ); + expect(result, SoftApBandType.band5GHz44); + }, + ); + + test( + 'matchBandTypeFromConfig returns correct type for 5GHz channel 149', + () { + final result = SoftApBandType.matchBandTypeFromConfig( + band: 2, + channel: 149, + channelWidth: 3, + ); + expect(result, SoftApBandType.band5GHz149); + }, + ); + + test( + 'matchBandTypeFromConfig returns correct type for 5GHz channel 157', + () { + final result = SoftApBandType.matchBandTypeFromConfig( + band: 2, + channel: 157, + channelWidth: 3, + ); + expect(result, SoftApBandType.band5GHz157); + }, + ); + + test( + 'matchBandTypeFromConfig defaults to band5GHz36 for unknown channel', + () { + final result = SoftApBandType.matchBandTypeFromConfig( + band: 2, + channel: 999, + channelWidth: 3, + ); + expect(result, SoftApBandType.band5GHz36); + }, + ); + + test('enum values have correct properties', () { + expect(SoftApBandType.band2_4GHz.name, "2.4 GHz"); + expect(SoftApBandType.band2_4GHz.band, 1); + + expect(SoftApBandType.band5GHz36.name, "5 GHZ - Channel 36"); + expect(SoftApBandType.band5GHz36.band, 2); + }); + }); +} diff --git a/test/unit/models/system_configuration_state_test.dart b/test/unit/models/system_configuration_state_test.dart new file mode 100644 index 0000000..899c055 --- /dev/null +++ b/test/unit/models/system_configuration_state_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tesla_android/feature/settings/bloc/system_configuration_state.dart'; +import 'package:tesla_android/feature/settings/model/softap_band_type.dart'; +import 'package:tesla_android/feature/settings/model/system_configuration_response_body.dart'; + +void main() { + group('SystemConfigurationState', () { + final mockConfig = SystemConfigurationResponseBody( + isEnabledFlag: 1, + bandType: 1, + channel: 6, + channelWidth: 2, + isOfflineModeEnabledFlag: 0, + isOfflineModeTelemetryEnabledFlag: 0, + isOfflineModeTeslaFirmwareDownloadsEnabledFlag: 0, + browserAudioIsEnabled: 1, + browserAudioVolume: 100, + isGPSEnabled: 1, + ); + + test('SystemConfigurationStateInitial instantiation', () { + expect( + SystemConfigurationStateInitial(), + isA(), + ); + }); + + test('SystemConfigurationStateLoading instantiation', () { + expect( + SystemConfigurationStateLoading(), + isA(), + ); + }); + + test('SystemConfigurationStateSettingsFetched instantiation', () { + final state = SystemConfigurationStateSettingsFetched( + currentConfiguration: mockConfig, + ); + expect(state.currentConfiguration, mockConfig); + }); + + test('SystemConfigurationStateSettingsFetchingError instantiation', () { + expect( + SystemConfigurationStateSettingsFetchingError(), + isA(), + ); + }); + + test( + 'SystemConfigurationStateSettingsModified instantiation and copyWith', + () { + final state = SystemConfigurationStateSettingsModified( + currentConfiguration: mockConfig, + newBandType: SoftApBandType.band2_4GHz, + isSoftApEnabled: true, + isOfflineModeEnabled: false, + isOfflineModeTelemetryEnabled: false, + isOfflineModeTeslaFirmwareDownloadsEnabled: false, + ); + + expect(state.newBandType, SoftApBandType.band2_4GHz); + expect(state.isSoftApEnabled, true); + + final copy = state.copyWith( + isSoftApEnabled: false, + newBandType: SoftApBandType.band5GHz36, + ); + + expect(copy.isSoftApEnabled, false); + expect(copy.newBandType, SoftApBandType.band5GHz36); + expect(copy.isOfflineModeEnabled, false); // Should persist + }, + ); + + test( + 'SystemConfigurationStateSettingsModified.fromCurrentConfiguration', + () { + final state = + SystemConfigurationStateSettingsModified.fromCurrentConfiguration( + currentConfiguration: mockConfig, + ); + + // mockConfig has isEnabledFlag: 1 -> isSoftApEnabled: true + expect(state.isSoftApEnabled, true); + }, + ); + + test('SystemConfigurationStateSettingsSaved instantiation', () { + final state = SystemConfigurationStateSettingsSaved( + currentConfiguration: mockConfig, + ); + expect(state.currentConfiguration, mockConfig); + }); + + test('SystemConfigurationStateSettingsSavingFailedError instantiation', () { + expect( + SystemConfigurationStateSettingsSavingFailedError(), + isA(), + ); + }); + }); +} diff --git a/test/unit/models/system_configuration_test.dart b/test/unit/models/system_configuration_test.dart new file mode 100644 index 0000000..14b0549 --- /dev/null +++ b/test/unit/models/system_configuration_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tesla_android/feature/settings/model/system_configuration_response_body.dart'; +import '../../helpers/test_fixtures.dart'; + +void main() { + group('SystemConfigurationResponseBody', () { + test('creates instance with all fields', () { + final config = TestFixtures.defaultConfiguration; + + expect(config.bandType, 1); + expect(config.channel, 36); + expect(config.channelWidth, 80); + expect(config.isEnabledFlag, 1); + expect(config.isOfflineModeEnabledFlag, 0); + expect(config.browserAudioIsEnabled, 1); + expect(config.browserAudioVolume, 80); + expect(config.isGPSEnabled, 1); + }); + + test('fromJson deserializes correctly', () { + final config = SystemConfigurationResponseBody.fromJson( + TestFixtures.configurationJson, + ); + + expect(config.bandType, 1); + expect(config.channel, 36); + expect(config.channelWidth, 80); + expect(config.isEnabledFlag, 1); + expect(config.isOfflineModeEnabledFlag, 0); + expect(config.browserAudioIsEnabled, 1); + expect(config.browserAudioVolume, 80); + expect(config.isGPSEnabled, 1); + }); + + test('toJson serializes correctly', () { + final config = TestFixtures.defaultConfiguration; + final json = config.toJson(); + + expect(json['persist.tesla-android.softap.band_type'], 1); + expect(json['persist.tesla-android.softap.channel'], 36); + expect(json['persist.tesla-android.softap.channel_width'], 80); + expect(json['persist.tesla-android.softap.is_enabled'], 1); + expect(json['persist.tesla-android.offline-mode.is_enabled'], 0); + expect(json['persist.tesla-android.browser_audio.is_enabled'], 1); + expect(json['persist.tesla-android.browser_audio.volume'], 80); + expect(json['persist.tesla-android.gps.is_active'], 1); + }); + + test('round-trip serialization preserves values', () { + final original = TestFixtures.defaultConfiguration; + final json = original.toJson(); + final deserialized = SystemConfigurationResponseBody.fromJson(json); + + expect(deserialized.bandType, original.bandType); + expect(deserialized.channel, original.channel); + expect(deserialized.channelWidth, original.channelWidth); + expect(deserialized.isEnabledFlag, original.isEnabledFlag); + expect( + deserialized.browserAudioIsEnabled, + original.browserAudioIsEnabled, + ); + expect(deserialized.browserAudioVolume, original.browserAudioVolume); + expect(deserialized.isGPSEnabled, original.isGPSEnabled); + }); + + test('handles different Wi-Fi configurations', () { + final json = { + 'persist.tesla-android.softap.band_type': 2, + 'persist.tesla-android.softap.channel': 149, + 'persist.tesla-android.softap.channel_width': 160, + 'persist.tesla-android.softap.is_enabled': 0, + 'persist.tesla-android.offline-mode.is_enabled': 1, + 'persist.tesla-android.offline-mode.telemetry.is_enabled': 0, + 'persist.tesla-android.offline-mode.tesla-firmware-downloads': 0, + 'persist.tesla-android.browser_audio.is_enabled': 0, + 'persist.tesla-android.browser_audio.volume': 50, + 'persist.tesla-android.gps.is_active': 0, + }; + + final config = SystemConfigurationResponseBody.fromJson(json); + + expect(config.bandType, 2); + expect(config.channel, 149); + expect(config.channelWidth, 160); + expect(config.isEnabledFlag, 0); + expect(config.isOfflineModeEnabledFlag, 1); + expect(config.browserAudioIsEnabled, 0); + expect(config.browserAudioVolume, 50); + expect(config.isGPSEnabled, 0); + }); + }); +} diff --git a/test/unit/navigation/ta_page_factory_test.dart b/test/unit/navigation/ta_page_factory_test.dart new file mode 100644 index 0000000..91efb98 --- /dev/null +++ b/test/unit/navigation/ta_page_factory_test.dart @@ -0,0 +1,268 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/common/navigation/ta_page.dart'; +import 'package:tesla_android/common/navigation/ta_page_factory.dart'; +import 'package:tesla_android/feature/about/about_page.dart'; +import 'package:tesla_android/feature/donations/widget/donation_page.dart'; +import 'package:tesla_android/feature/settings/bloc/audio_configuration_state.dart'; +import 'package:tesla_android/feature/settings/bloc/gps_configuration_state.dart'; +import 'package:tesla_android/feature/display/cubit/display_state.dart'; +import 'package:tesla_android/feature/home/cubit/ota_update_state.dart'; +import 'package:tesla_android/feature/releaseNotes/cubit/release_notes_state.dart'; +import 'package:tesla_android/feature/settings/bloc/display_configuration_state.dart'; +import 'package:tesla_android/feature/settings/bloc/rear_display_configuration_state.dart'; +import 'package:tesla_android/feature/settings/bloc/system_configuration_state.dart'; +import 'package:tesla_android/feature/settings/bloc/device_info_state.dart'; +import 'package:tesla_android/feature/connectivityCheck/model/connectivity_state.dart'; +import 'package:tesla_android/common/service/audio_service.dart'; +import 'package:tesla_android/feature/settings/model/system_configuration_response_body.dart'; +import 'package:tesla_android/feature/settings/model/device_info.dart'; +import 'package:tesla_android/feature/display/model/remote_display_state.dart'; + +import '../../helpers/mock_cubits.mocks.dart'; + +void main() { + late TAPageFactory pageFactory; + late MockAudioConfigurationCubit mockAudioCubit; + late MockGPSConfigurationCubit mockGPSCubit; + late MockTouchscreenCubit mockTouchscreenCubit; + late MockDisplayCubit mockDisplayCubit; + late MockOTAUpdateCubit mockOTACubit; + late MockReleaseNotesCubit mockReleaseNotesCubit; + late MockSystemConfigurationCubit mockSystemCubit; + late MockDisplayConfigurationCubit mockDisplayConfigCubit; + late MockRearDisplayConfigurationCubit mockRearDisplayCubit; + late MockDeviceInfoCubit mockDeviceInfoCubit; + late MockConnectivityCheckCubit mockConnectivityCubit; + late MockAudioService mockAudioService; + + setUp(() { + pageFactory = TAPageFactory(); + + // Create mocks + mockAudioCubit = MockAudioConfigurationCubit(); + mockGPSCubit = MockGPSConfigurationCubit(); + mockTouchscreenCubit = MockTouchscreenCubit(); + mockDisplayCubit = MockDisplayCubit(); + mockOTACubit = MockOTAUpdateCubit(); + mockReleaseNotesCubit = MockReleaseNotesCubit(); + mockSystemCubit = MockSystemConfigurationCubit(); + mockDisplayConfigCubit = MockDisplayConfigurationCubit(); + mockRearDisplayCubit = MockRearDisplayConfigurationCubit(); + mockDeviceInfoCubit = MockDeviceInfoCubit(); + mockConnectivityCubit = MockConnectivityCheckCubit(); + mockAudioService = MockAudioService(); + + // Register factories in GetIt for page building + GetIt.I.registerFactory(() => mockAudioCubit); + GetIt.I.registerFactory(() => mockGPSCubit); + GetIt.I.registerFactory(() => mockTouchscreenCubit); + GetIt.I.registerFactory(() => mockDisplayCubit); + GetIt.I.registerFactory(() => mockOTACubit); + GetIt.I.registerFactory(() => mockReleaseNotesCubit); + GetIt.I.registerFactory(() => mockSystemCubit); + GetIt.I.registerFactory(() => mockDisplayConfigCubit); + GetIt.I.registerFactory(() => mockRearDisplayCubit); + GetIt.I.registerFactory(() => mockDeviceInfoCubit); + GetIt.I.registerFactory(() => mockConnectivityCubit); + GetIt.I.registerSingleton(mockAudioService); + + // Stub cubit methods & states + when(mockOTACubit.checkForUpdates()).thenAnswer((_) async {}); + when(mockGPSCubit.fetchConfiguration()).thenAnswer((_) async {}); + when(mockAudioCubit.fetchConfiguration()).thenAnswer((_) async {}); + when(mockDisplayConfigCubit.fetchConfiguration()).thenAnswer((_) async {}); + when(mockRearDisplayCubit.fetchConfiguration()).thenAnswer((_) async {}); + when(mockSystemCubit.fetchConfiguration()).thenAnswer((_) async {}); + when(mockDeviceInfoCubit.fetchConfiguration()).thenAnswer((_) async {}); + when(mockAudioService.getAudioState()).thenReturn("stopped"); + + // Default States + when(mockAudioCubit.state).thenReturn( + AudioConfigurationStateSettingsFetched(isEnabled: true, volume: 100), + ); + when(mockAudioCubit.stream).thenAnswer((_) => const Stream.empty()); + + when( + mockGPSCubit.state, + ).thenReturn(GPSConfigurationStateLoaded(isGPSEnabled: true)); + when(mockGPSCubit.stream).thenAnswer((_) => const Stream.empty()); + + when(mockTouchscreenCubit.state).thenReturn(false); + when(mockTouchscreenCubit.stream).thenAnswer((_) => const Stream.empty()); + + when(mockDisplayCubit.state).thenReturn(DisplayStateInitial()); + when(mockDisplayCubit.stream).thenAnswer((_) => const Stream.empty()); + + when(mockOTACubit.state).thenReturn(OTAUpdateStateInitial()); + when(mockOTACubit.stream).thenAnswer((_) => const Stream.empty()); + + when(mockReleaseNotesCubit.state).thenReturn(ReleaseNotesStateInitial()); + when(mockReleaseNotesCubit.stream).thenAnswer((_) => const Stream.empty()); + + when(mockDisplayConfigCubit.state).thenReturn( + DisplayConfigurationStateSettingsFetched( + resolutionPreset: DisplayResolutionModePreset.res720p, + renderer: DisplayRendererType.mjpeg, + isResponsive: true, + quality: DisplayQualityPreset.quality80, + refreshRate: DisplayRefreshRatePreset.refresh30hz, + ), + ); + when(mockDisplayConfigCubit.stream).thenAnswer((_) => const Stream.empty()); + + when(mockRearDisplayCubit.state).thenReturn( + RearDisplayConfigurationStateSettingsFetched( + isRearDisplayEnabled: false, + isCurrentDisplayPrimary: true, + isRearDisplayPrioritised: false, + ), + ); + when(mockRearDisplayCubit.stream).thenAnswer((_) => const Stream.empty()); + + when(mockSystemCubit.state).thenReturn( + SystemConfigurationStateSettingsFetched( + currentConfiguration: SystemConfigurationResponseBody( + isEnabledFlag: 1, + bandType: 1, + channel: 6, + channelWidth: 2, + isOfflineModeEnabledFlag: 0, + isOfflineModeTelemetryEnabledFlag: 0, + isOfflineModeTeslaFirmwareDownloadsEnabledFlag: 0, + browserAudioIsEnabled: 1, + browserAudioVolume: 100, + isGPSEnabled: 1, + ), + ), + ); + when(mockSystemCubit.stream).thenAnswer((_) => const Stream.empty()); + + when(mockDeviceInfoCubit.state).thenReturn( + DeviceInfoStateLoaded( + deviceInfo: const DeviceInfo( + cpuTemperature: 40, + serialNumber: "123", + deviceModel: "Pi4", + isCarPlayDetected: 0, + isModemDetected: 0, + releaseType: "stable", + otaUrl: "url", + isGPSEnabled: 1, + ), + ), + ); + when(mockDeviceInfoCubit.stream).thenAnswer((_) => const Stream.empty()); + + when( + mockConnectivityCubit.state, + ).thenReturn(ConnectivityState.backendAccessible); + when(mockConnectivityCubit.stream).thenAnswer((_) => const Stream.empty()); + + PackageInfo.setMockInitialValues( + appName: 'Tesla Android', + packageName: 'com.teslaandroid', + version: '1.0.0', + buildNumber: '1', + buildSignature: '', + ); + + GetIt.I.registerSingleton(pageFactory); + }); + + tearDown(() { + GetIt.I.reset(); + }); + + group('TAPageFactory', () { + test('getRoutes returns map with all available page routes', () { + final routes = pageFactory.getRoutes(); + expect(routes, isNotEmpty); + expect(routes.length, equals(TAPage.availablePages.length)); + }); + + testWidgets('buildPage(home) returns MultiBlocProvider', (tester) async { + late Widget builtWidget; + await tester.pumpWidget( + Builder( + builder: (context) { + builtWidget = pageFactory.buildPage(TAPage.home)(context); + return const SizedBox(); + }, + ), + ); + expect(builtWidget, isA()); + }); + + testWidgets('buildPage(settings) returns MultiBlocProvider', ( + tester, + ) async { + late Widget builtWidget; + await tester.pumpWidget( + Builder( + builder: (context) { + builtWidget = pageFactory.buildPage(TAPage.settings)(context); + return const SizedBox(); + }, + ), + ); + expect(builtWidget, isA()); + }); + + testWidgets('buildPage(releaseNotes) returns BlocProvider', (tester) async { + late Widget builtWidget; + await tester.pumpWidget( + Builder( + builder: (context) { + builtWidget = pageFactory.buildPage(TAPage.releaseNotes)(context); + return const SizedBox(); + }, + ), + ); + expect(builtWidget, isA()); + }); + + testWidgets('buildPage(about) returns AboutPage', (tester) async { + late Widget builtWidget; + await tester.pumpWidget( + Builder( + builder: (context) { + builtWidget = pageFactory.buildPage(TAPage.about)(context); + return const SizedBox(); + }, + ), + ); + expect(builtWidget, isA()); + }); + + testWidgets('buildPage(donations) returns DonationPage', (tester) async { + late Widget builtWidget; + await tester.pumpWidget( + Builder( + builder: (context) { + builtWidget = pageFactory.buildPage(TAPage.donations)(context); + return const SizedBox(); + }, + ), + ); + expect(builtWidget, isA()); + }); + + testWidgets('buildPage(empty) returns SizedBox', (tester) async { + late Widget builtWidget; + await tester.pumpWidget( + Builder( + builder: (context) { + builtWidget = pageFactory.buildPage(TAPage.empty)(context); + return const SizedBox(); + }, + ), + ); + expect(builtWidget, isA()); + }); + }); +} diff --git a/test/unit/repositories/display_repository_test.dart b/test/unit/repositories/display_repository_test.dart new file mode 100644 index 0000000..5230c31 --- /dev/null +++ b/test/unit/repositories/display_repository_test.dart @@ -0,0 +1,145 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tesla_android/common/network/display_service.dart'; +import 'package:tesla_android/feature/display/repository/display_repository.dart'; +import '../../helpers/test_fixtures.dart'; + +import 'display_repository_test.mocks.dart'; + +@GenerateMocks([DisplayService, SharedPreferences]) +void main() { + late MockDisplayService mockService; + late MockSharedPreferences mockPreferences; + late DisplayRepository repository; + + setUp(() { + mockService = MockDisplayService(); + mockPreferences = MockSharedPreferences(); + repository = DisplayRepository(mockService, mockPreferences); + }); + + group('DisplayRepository', () { + test('getDisplayState returns display state from service', () async { + when( + mockService.getDisplayState(), + ).thenAnswer((_) async => TestFixtures.defaultDisplayState); + + final result = await repository.getDisplayState(); + + expect(result, equals(TestFixtures.defaultDisplayState)); + verify(mockService.getDisplayState()).called(1); + }); + + test('updateDisplayConfiguration calls service', () async { + when( + mockService.updateDisplayConfiguration(any), + ).thenAnswer((_) async => {}); + + await repository.updateDisplayConfiguration( + TestFixtures.defaultDisplayState, + ); + + verify( + mockService.updateDisplayConfiguration( + TestFixtures.defaultDisplayState, + ), + ).called(1); + }); + + test('isPrimaryDisplay returns true when rear display disabled', () async { + final stateWithoutRear = TestFixtures.defaultDisplayState.copyWith( + isRearDisplayEnabled: 0, + ); + when( + mockService.getDisplayState(), + ).thenAnswer((_) async => stateWithoutRear); + + final result = await repository.isPrimaryDisplay(); + + expect(result, true); + }); + + test( + 'isPrimaryDisplay returns preference when rear display enabled', + () async { + final stateWithRear = TestFixtures.defaultDisplayState.copyWith( + isRearDisplayEnabled: 1, + ); + when( + mockService.getDisplayState(), + ).thenAnswer((_) async => stateWithRear); + when(mockPreferences.getBool(any)).thenReturn(true); + + final result = await repository.isPrimaryDisplay(); + + expect(result, true); + verify( + mockPreferences.getBool( + 'DisplayRepository_isPrimaryDisplaySharedPreferencesKey', + ), + ).called(1); + }, + ); + + test('isPrimaryDisplay returns null when preference not set', () async { + final stateWithRear = TestFixtures.defaultDisplayState.copyWith( + isRearDisplayEnabled: 1, + ); + when( + mockService.getDisplayState(), + ).thenAnswer((_) async => stateWithRear); + when(mockPreferences.getBool(any)).thenReturn(null); + + final result = await repository.isPrimaryDisplay(); + + expect(result, null); + }); + + test('setDisplayType saves preference', () async { + when(mockPreferences.setBool(any, any)).thenAnswer((_) async => true); + + await repository.setDisplayType(true); + + verify( + mockPreferences.setBool( + 'DisplayRepository_isPrimaryDisplaySharedPreferencesKey', + true, + ), + ).called(1); + }); + + test('setDisplayType can set false', () async { + when(mockPreferences.setBool(any, any)).thenAnswer((_) async => true); + + await repository.setDisplayType(false); + + verify( + mockPreferences.setBool( + 'DisplayRepository_isPrimaryDisplaySharedPreferencesKey', + false, + ), + ).called(1); + }); + + test('handles service errors in getDisplayState', () async { + when(mockService.getDisplayState()).thenThrow(Exception('Network error')); + + expect(() => repository.getDisplayState(), throwsException); + }); + + test('handles service errors in updateDisplayConfiguration', () async { + when( + mockService.updateDisplayConfiguration(any), + ).thenThrow(Exception('Update failed')); + + expect( + () => repository.updateDisplayConfiguration( + TestFixtures.defaultDisplayState, + ), + throwsException, + ); + }); + }); +} diff --git a/test/unit/repositories/display_repository_test.mocks.dart b/test/unit/repositories/display_repository_test.mocks.dart new file mode 100644 index 0000000..d9e6a2c --- /dev/null +++ b/test/unit/repositories/display_repository_test.mocks.dart @@ -0,0 +1,187 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in tesla_android/test/unit/repositories/display_repository_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i4; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:shared_preferences/src/shared_preferences_legacy.dart' as _i5; +import 'package:tesla_android/common/network/display_service.dart' as _i3; +import 'package:tesla_android/feature/display/model/remote_display_state.dart' + as _i2; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeRemoteDisplayState_0 extends _i1.SmartFake + implements _i2.RemoteDisplayState { + _FakeRemoteDisplayState_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +/// A class which mocks [DisplayService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockDisplayService extends _i1.Mock implements _i3.DisplayService { + MockDisplayService() { + _i1.throwOnMissingStub(this); + } + + @override + _i4.Future<_i2.RemoteDisplayState> getDisplayState() => + (super.noSuchMethod( + Invocation.method(#getDisplayState, []), + returnValue: _i4.Future<_i2.RemoteDisplayState>.value( + _FakeRemoteDisplayState_0( + this, + Invocation.method(#getDisplayState, []), + ), + ), + ) + as _i4.Future<_i2.RemoteDisplayState>); + + @override + _i4.Future updateDisplayConfiguration( + _i2.RemoteDisplayState? configuration, + ) => + (super.noSuchMethod( + Invocation.method(#updateDisplayConfiguration, [configuration]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); +} + +/// A class which mocks [SharedPreferences]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockSharedPreferences extends _i1.Mock implements _i5.SharedPreferences { + MockSharedPreferences() { + _i1.throwOnMissingStub(this); + } + + @override + Set getKeys() => + (super.noSuchMethod( + Invocation.method(#getKeys, []), + returnValue: {}, + ) + as Set); + + @override + Object? get(String? key) => + (super.noSuchMethod(Invocation.method(#get, [key])) as Object?); + + @override + bool? getBool(String? key) => + (super.noSuchMethod(Invocation.method(#getBool, [key])) as bool?); + + @override + int? getInt(String? key) => + (super.noSuchMethod(Invocation.method(#getInt, [key])) as int?); + + @override + double? getDouble(String? key) => + (super.noSuchMethod(Invocation.method(#getDouble, [key])) as double?); + + @override + String? getString(String? key) => + (super.noSuchMethod(Invocation.method(#getString, [key])) as String?); + + @override + bool containsKey(String? key) => + (super.noSuchMethod( + Invocation.method(#containsKey, [key]), + returnValue: false, + ) + as bool); + + @override + List? getStringList(String? key) => + (super.noSuchMethod(Invocation.method(#getStringList, [key])) + as List?); + + @override + _i4.Future setBool(String? key, bool? value) => + (super.noSuchMethod( + Invocation.method(#setBool, [key, value]), + returnValue: _i4.Future.value(false), + ) + as _i4.Future); + + @override + _i4.Future setInt(String? key, int? value) => + (super.noSuchMethod( + Invocation.method(#setInt, [key, value]), + returnValue: _i4.Future.value(false), + ) + as _i4.Future); + + @override + _i4.Future setDouble(String? key, double? value) => + (super.noSuchMethod( + Invocation.method(#setDouble, [key, value]), + returnValue: _i4.Future.value(false), + ) + as _i4.Future); + + @override + _i4.Future setString(String? key, String? value) => + (super.noSuchMethod( + Invocation.method(#setString, [key, value]), + returnValue: _i4.Future.value(false), + ) + as _i4.Future); + + @override + _i4.Future setStringList(String? key, List? value) => + (super.noSuchMethod( + Invocation.method(#setStringList, [key, value]), + returnValue: _i4.Future.value(false), + ) + as _i4.Future); + + @override + _i4.Future remove(String? key) => + (super.noSuchMethod( + Invocation.method(#remove, [key]), + returnValue: _i4.Future.value(false), + ) + as _i4.Future); + + @override + _i4.Future commit() => + (super.noSuchMethod( + Invocation.method(#commit, []), + returnValue: _i4.Future.value(false), + ) + as _i4.Future); + + @override + _i4.Future clear() => + (super.noSuchMethod( + Invocation.method(#clear, []), + returnValue: _i4.Future.value(false), + ) + as _i4.Future); + + @override + _i4.Future reload() => + (super.noSuchMethod( + Invocation.method(#reload, []), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); +} diff --git a/test/unit/repositories/repository_test.dart b/test/unit/repositories/repository_test.dart new file mode 100644 index 0000000..ee8341b --- /dev/null +++ b/test/unit/repositories/repository_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/feature/home/model/github_release.dart'; +import 'package:tesla_android/feature/home/repository/github_release_repository.dart'; +import 'package:tesla_android/feature/releaseNotes/repository/release_notes_repository.dart'; +import 'package:tesla_android/feature/settings/model/device_info.dart'; +import 'package:tesla_android/feature/settings/repository/device_info_repository.dart'; + +import '../../helpers/mock_services.mocks.dart'; + +void main() { + group('Repository Tests', () { + group('GitHubReleaseRepository', () { + late MockGitHubService mockService; + late MockDeviceInfoService mockDeviceInfoService; + late GitHubReleaseRepository repository; + + setUp(() { + mockService = MockGitHubService(); + mockDeviceInfoService = MockDeviceInfoService(); + repository = GitHubReleaseRepository( + mockService, + mockDeviceInfoService, + ); + }); + + test('getLatestRelease calls service', () async { + const release = GitHubRelease(name: 'v1.0.0'); + when(mockService.getLatestRelease()).thenAnswer((_) async => release); + + final result = await repository.getLatestRelease(); + + expect(result, release); + verify(mockService.getLatestRelease()).called(1); + }); + + test('openUpdater calls device info service', () async { + when(mockDeviceInfoService.openUpdater()).thenAnswer((_) async {}); + + await repository.openUpdater(); + + verify(mockDeviceInfoService.openUpdater()).called(1); + }); + }); + + group('DeviceInfoRepository', () { + late MockDeviceInfoService mockService; + late DeviceInfoRepository repository; + + setUp(() { + mockService = MockDeviceInfoService(); + repository = DeviceInfoRepository(mockService); + }); + + test('getDeviceInfo calls service', () async { + const info = DeviceInfo( + cpuTemperature: 50, + serialNumber: '123', + deviceModel: 'Pi4', + isCarPlayDetected: 1, + isModemDetected: 1, + releaseType: 'stable', + otaUrl: 'http://ota.url', + isGPSEnabled: 1, + ); + when(mockService.getDeviceInfo()).thenAnswer((_) async => info); + + final result = await repository.getDeviceInfo(); + + expect(result, info); + verify(mockService.getDeviceInfo()).called(1); + }); + }); + + group('ReleaseNotesRepository', () { + late ReleaseNotesRepository repository; + + setUp(() { + repository = ReleaseNotesRepository(); + }); + + test('getReleaseNotes returns non-empty notes', () async { + final notes = await repository.getReleaseNotes(); + expect(notes.versions, isNotEmpty); + }); + }); + }); +} diff --git a/test/unit/repositories/system_configuration_repository_test.dart b/test/unit/repositories/system_configuration_repository_test.dart new file mode 100644 index 0000000..87830d7 --- /dev/null +++ b/test/unit/repositories/system_configuration_repository_test.dart @@ -0,0 +1,142 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/common/network/configuration_service.dart'; +import 'package:tesla_android/feature/settings/repository/system_configuration_repository.dart'; +import '../../helpers/test_fixtures.dart'; + +import 'system_configuration_repository_test.mocks.dart'; + +@GenerateMocks([ConfigurationService]) +void main() { + late MockConfigurationService mockService; + late SystemConfigurationRepository repository; + + setUp(() { + mockService = MockConfigurationService(); + repository = SystemConfigurationRepository(mockService); + }); + + group('SystemConfigurationRepository', () { + test('getConfiguration returns configuration from service', () async { + when( + mockService.getConfiguration(), + ).thenAnswer((_) async => TestFixtures.defaultConfiguration); + + final result = await repository.getConfiguration(); + + expect(result, equals(TestFixtures.defaultConfiguration)); + verify(mockService.getConfiguration()).called(1); + }); + + test('setSoftApBand calls service with correct value', () async { + when(mockService.setSoftApBand(2)).thenAnswer((_) async => {}); + + await repository.setSoftApBand(2); + + verify(mockService.setSoftApBand(2)).called(1); + }); + + test('setSoftApChannel calls service with correct value', () async { + when(mockService.setSoftApChannel(149)).thenAnswer((_) async => {}); + + await repository.setSoftApChannel(149); + + verify(mockService.setSoftApChannel(149)).called(1); + }); + + test('setSoftApChannelWidth calls service with correct value', () async { + when(mockService.setSoftApChannelWidth(160)).thenAnswer((_) async => {}); + + await repository.setSoftApChannelWidth(160); + + verify(mockService.setSoftApChannelWidth(160)).called(1); + }); + + test('setSoftApState enables soft AP', () async { + when(mockService.setSoftApState(1)).thenAnswer((_) async => {}); + + await repository.setSoftApState(1); + + verify(mockService.setSoftApState(1)).called(1); + }); + + test('setSoftApState disables soft AP', () async { + when(mockService.setSoftApState(0)).thenAnswer((_) async => {}); + + await repository.setSoftApState(0); + + verify(mockService.setSoftApState(0)).called(1); + }); + + test('setOfflineModeState enables offline mode', () async { + when(mockService.setOfflineModeState(1)).thenAnswer((_) async => {}); + + await repository.setOfflineModeState(1); + + verify(mockService.setOfflineModeState(1)).called(1); + }); + + test('setBrowserAudioState enables audio', () async { + when(mockService.setBrowserAudioState(1)).thenAnswer((_) async => {}); + + await repository.setBrowserAudioState(1); + + verify(mockService.setBrowserAudioState(1)).called(1); + }); + + test('setBrowserAudioVolume sets volume level', () async { + when(mockService.setBrowserAudioVolume(75)).thenAnswer((_) async => {}); + + await repository.setBrowserAudioVolume(75); + + verify(mockService.setBrowserAudioVolume(75)).called(1); + }); + + test('setGPSState enables GPS', () async { + when(mockService.setGPSState(1)).thenAnswer((_) async => {}); + + await repository.setGPSState(1); + + verify(mockService.setGPSState(1)).called(1); + }); + + test('setGPSState disables GPS', () async { + when(mockService.setGPSState(0)).thenAnswer((_) async => {}); + + await repository.setGPSState(0); + + verify(mockService.setGPSState(0)).called(1); + }); + + test('handles service errors in getConfiguration', () async { + when( + mockService.getConfiguration(), + ).thenThrow(Exception('Network error')); + + expect(() => repository.getConfiguration(), throwsException); + }); + + test('handles service errors in setSoftApBand', () async { + when( + mockService.setSoftApBand(any), + ).thenThrow(Exception('Update failed')); + + expect(() => repository.setSoftApBand(2), throwsException); + }); + + test('handles service errors in setBrowserAudioVolume', () async { + when( + mockService.setBrowserAudioVolume(any), + ).thenThrow(Exception('Update failed')); + + expect(() => repository.setBrowserAudioVolume(80), throwsException); + }); + + test('handles service errors in setGPSState', () async { + when(mockService.setGPSState(any)).thenThrow(Exception('Update failed')); + + expect(() => repository.setGPSState(1), throwsException); + }); + }); +} diff --git a/test/unit/repositories/system_configuration_repository_test.mocks.dart b/test/unit/repositories/system_configuration_repository_test.mocks.dart new file mode 100644 index 0000000..72f0d0c --- /dev/null +++ b/test/unit/repositories/system_configuration_repository_test.mocks.dart @@ -0,0 +1,140 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in tesla_android/test/unit/repositories/system_configuration_repository_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i4; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:tesla_android/common/network/configuration_service.dart' as _i3; +import 'package:tesla_android/feature/settings/model/system_configuration_response_body.dart' + as _i2; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeSystemConfigurationResponseBody_0 extends _i1.SmartFake + implements _i2.SystemConfigurationResponseBody { + _FakeSystemConfigurationResponseBody_0( + Object parent, + Invocation parentInvocation, + ) : super(parent, parentInvocation); +} + +/// A class which mocks [ConfigurationService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockConfigurationService extends _i1.Mock + implements _i3.ConfigurationService { + MockConfigurationService() { + _i1.throwOnMissingStub(this); + } + + @override + _i4.Future<_i2.SystemConfigurationResponseBody> getConfiguration() => + (super.noSuchMethod( + Invocation.method(#getConfiguration, []), + returnValue: _i4.Future<_i2.SystemConfigurationResponseBody>.value( + _FakeSystemConfigurationResponseBody_0( + this, + Invocation.method(#getConfiguration, []), + ), + ), + ) + as _i4.Future<_i2.SystemConfigurationResponseBody>); + + @override + _i4.Future setSoftApBand(int? band) => + (super.noSuchMethod( + Invocation.method(#setSoftApBand, [band]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setSoftApChannel(int? channel) => + (super.noSuchMethod( + Invocation.method(#setSoftApChannel, [channel]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setSoftApChannelWidth(int? channelWidth) => + (super.noSuchMethod( + Invocation.method(#setSoftApChannelWidth, [channelWidth]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setSoftApState(int? isEnabledFlag) => + (super.noSuchMethod( + Invocation.method(#setSoftApState, [isEnabledFlag]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setOfflineModeState(int? isEnabledFlag) => + (super.noSuchMethod( + Invocation.method(#setOfflineModeState, [isEnabledFlag]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setOfflineModeTelemetryState(int? isEnabledFlag) => + (super.noSuchMethod( + Invocation.method(#setOfflineModeTelemetryState, [isEnabledFlag]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setOfflineModeTeslaFirmwareDownloads( + int? isEnabledFlag, + ) => + (super.noSuchMethod( + Invocation.method(#setOfflineModeTeslaFirmwareDownloads, [ + isEnabledFlag, + ]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setBrowserAudioState(int? isEnabledFlag) => + (super.noSuchMethod( + Invocation.method(#setBrowserAudioState, [isEnabledFlag]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setBrowserAudioVolume(int? volume) => + (super.noSuchMethod( + Invocation.method(#setBrowserAudioVolume, [volume]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setGPSState(int? state) => + (super.noSuchMethod( + Invocation.method(#setGPSState, [state]), + returnValue: _i4.Future.value(), + ) + as _i4.Future); +} diff --git a/test/unit/services/configuration_service_test.dart b/test/unit/services/configuration_service_test.dart new file mode 100644 index 0000000..0dd8e16 --- /dev/null +++ b/test/unit/services/configuration_service_test.dart @@ -0,0 +1,139 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/common/network/configuration_service.dart'; +import 'package:tesla_android/feature/settings/model/system_configuration_response_body.dart'; + +import '../../helpers/mock_services.mocks.dart'; + +void main() { + group('ConfigurationService', () { + late MockDio mockDio; + late MockFlavor mockFlavor; + late ConfigurationService service; + + setUp(() { + mockDio = MockDio(); + mockFlavor = MockFlavor(); + + when( + mockFlavor.getString("configurationApiBaseUrl"), + ).thenReturn("http://localhost"); + + // Mock Dio's options to avoid NPEs if accessed by the service + when(mockDio.options).thenReturn(BaseOptions()); + + // Mock fetch method which is used by Retrofit + // Retrofit uses fetch internally + when(mockDio.fetch>(any)).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: ''), + data: {}, + statusCode: 200, + ), + ); + + service = ConfigurationService(mockDio, mockFlavor); + }); + + test('getConfiguration calls correct endpoint', () async { + final responseData = { + 'persist.tesla-android.softap.band_type': 1, + 'persist.tesla-android.softap.channel': 6, + 'persist.tesla-android.softap.channel_width': 2, + 'persist.tesla-android.softap.is_enabled': 1, + 'persist.tesla-android.offline-mode.is_enabled': 0, + 'persist.tesla-android.offline-mode.telemetry.is_enabled': 1, + 'persist.tesla-android.offline-mode.tesla-firmware-downloads': 0, + 'persist.tesla-android.browser_audio.is_enabled': 1, + 'persist.tesla-android.browser_audio.volume': 100, + 'persist.tesla-android.gps.is_active': 1, + }; + + when(mockDio.fetch>(any)).thenAnswer(( + invocation, + ) async { + return Response( + requestOptions: RequestOptions(path: ''), + data: responseData, + statusCode: 200, + ); + }); + + final result = await service.getConfiguration(); + + expect(result, isA()); + expect(result.bandType, 1); + + verify( + mockDio.fetch>( + argThat( + predicate((options) { + return options.method == 'GET' && + options.path == '/configuration' && + options.baseUrl == 'http://localhost'; + }), + ), + ), + ).called(1); + }); + + test('setSoftApBand calls correct endpoint with body', () async { + when(mockDio.fetch(any)).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: ''), + data: null, + statusCode: 200, + ), + ); + + await service.setSoftApBand(1); + + verify( + mockDio.fetch( + argThat( + predicate((options) { + return options.method == 'POST' && + options.path == '/softApBand' && + options.data == 1; + }), + ), + ), + ).called(1); + }); + + test('getConfiguration handles network error', () async { + when(mockDio.fetch>(any)).thenThrow( + DioException( + requestOptions: RequestOptions(path: '/configuration'), + type: DioExceptionType.connectionError, + message: 'Network error', + ), + ); + + expect( + () async => await service.getConfiguration(), + throwsA(isA()), + ); + }); + + test('setSoftApBand handles server error', () async { + when(mockDio.fetch(any)).thenThrow( + DioException( + requestOptions: RequestOptions(path: '/softApBand'), + response: Response( + requestOptions: RequestOptions(path: '/softApBand'), + statusCode: 500, + statusMessage: 'Internal Server Error', + ), + type: DioExceptionType.badResponse, + ), + ); + + expect( + () async => await service.setSoftApBand(1), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/unit/services/device_info_service_test.dart b/test/unit/services/device_info_service_test.dart new file mode 100644 index 0000000..0f34ca3 --- /dev/null +++ b/test/unit/services/device_info_service_test.dart @@ -0,0 +1,108 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/common/network/device_info_service.dart'; +import 'package:tesla_android/feature/settings/model/device_info.dart'; + +import '../../helpers/mock_services.mocks.dart'; + +void main() { + group('DeviceInfoService', () { + late MockDio mockDio; + late MockFlavor mockFlavor; + late DeviceInfoService service; + + setUp(() { + mockDio = MockDio(); + mockFlavor = MockFlavor(); + + when( + mockFlavor.getString("configurationApiBaseUrl"), + ).thenReturn("http://localhost"); + + when(mockDio.options).thenReturn(BaseOptions()); + + service = DeviceInfoService(mockDio, mockFlavor); + }); + + test('getDeviceInfo calls correct endpoint', () async { + final responseData = { + 'cpu_temperature': 45, + 'serial_number': '123456', + 'device_model': 'Raspberry Pi 4', + 'is_carplay_detected': 1, + 'is_modem_detected': 0, + 'release_type': 'stable', + 'ota_url': 'http://ota.url', + 'persist.tesla-android.gps.is_active': 1, + }; + + when(mockDio.fetch>(any)).thenAnswer(( + invocation, + ) async { + return Response( + requestOptions: RequestOptions(path: ''), + data: responseData, + statusCode: 200, + ); + }); + + final result = await service.getDeviceInfo(); + + expect(result, isA()); + expect(result.cpuTemperature, 45); + expect(result.serialNumber, '123456'); + + verify( + mockDio.fetch>( + argThat( + predicate((options) { + return options.method == 'GET' && + options.path == '/deviceInfo' && + options.baseUrl == 'http://localhost'; + }), + ), + ), + ).called(1); + }); + + test('openUpdater calls correct endpoint', () async { + when(mockDio.fetch(any)).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: ''), + data: null, + statusCode: 200, + ), + ); + + await service.openUpdater(); + + verify( + mockDio.fetch( + argThat( + predicate((options) { + return options.method == 'GET' && + options.path == '/openUpdater' && + options.baseUrl == 'http://localhost'; + }), + ), + ), + ).called(1); + }); + + test('getDeviceInfo handles timeout', () async { + when(mockDio.fetch>(any)).thenThrow( + DioException( + requestOptions: RequestOptions(path: '/deviceInfo'), + type: DioExceptionType.receiveTimeout, + message: 'Receive timeout', + ), + ); + + expect( + () async => await service.getDeviceInfo(), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/unit/services/display_service_test.dart b/test/unit/services/display_service_test.dart new file mode 100644 index 0000000..e1da80f --- /dev/null +++ b/test/unit/services/display_service_test.dart @@ -0,0 +1,114 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/common/network/display_service.dart'; +import 'package:tesla_android/feature/display/model/remote_display_state.dart'; + +import '../../helpers/mock_services.mocks.dart'; + +void main() { + group('DisplayService', () { + late MockDio mockDio; + late MockFlavor mockFlavor; + late DisplayService service; + + setUp(() { + mockDio = MockDio(); + mockFlavor = MockFlavor(); + + when( + mockFlavor.getString("configurationApiBaseUrl"), + ).thenReturn("http://localhost"); + + when(mockDio.options).thenReturn(BaseOptions()); + + service = DisplayService(mockDio, mockFlavor); + }); + + test('getDisplayState calls correct endpoint', () async { + final responseData = { + 'width': 1920, + 'height': 1080, + 'density': 160, + 'resolutionPreset': 1, // 720p + 'renderer': 0, // mjpeg + 'isResponsive': 1, + 'isH264': 0, + 'refreshRate': 60, + 'quality': 90, + 'isRearDisplayEnabled': 0, + 'isRearDisplayPrioritised': 0, + }; + + when(mockDio.fetch>(any)).thenAnswer(( + invocation, + ) async { + return Response( + requestOptions: RequestOptions(path: ''), + data: responseData, + statusCode: 200, + ); + }); + + final result = await service.getDisplayState(); + + expect(result, isA()); + expect(result.width, 1920); + expect(result.height, 1080); + + verify( + mockDio.fetch>( + argThat( + predicate((options) { + return options.method == 'GET' && + options.path == '/displayState' && + options.baseUrl == 'http://localhost'; + }), + ), + ), + ).called(1); + }); + + test( + 'updateDisplayConfiguration calls correct endpoint with body', + () async { + final config = RemoteDisplayState( + width: 1280, + height: 720, + density: 160, + refreshRate: DisplayRefreshRatePreset.refresh30hz, + resolutionPreset: DisplayResolutionModePreset.res720p, + renderer: DisplayRendererType.mjpeg, + isResponsive: 1, + isH264: 0, + quality: DisplayQualityPreset.quality90, + isRearDisplayEnabled: 0, + isRearDisplayPrioritised: 0, + ); + + when(mockDio.fetch(any)).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: ''), + data: null, + statusCode: 200, + ), + ); + + await service.updateDisplayConfiguration(config); + + verify( + mockDio.fetch( + argThat( + predicate((options) { + return options.method == 'POST' && + options.path == '/displayState' && + options.baseUrl == 'http://localhost' && + options.headers['Content-Type'] == 'application/json'; + }), + ), + ), + ).called(1); + }, + ); + }); +} diff --git a/test/unit/services/github_service_test.dart b/test/unit/services/github_service_test.dart new file mode 100644 index 0000000..8715c70 --- /dev/null +++ b/test/unit/services/github_service_test.dart @@ -0,0 +1,62 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/common/network/github_service.dart'; +import 'package:tesla_android/feature/home/model/github_release.dart'; + +import '../../helpers/mock_services.mocks.dart'; + +void main() { + group('GitHubService', () { + late MockDio mockDio; + late MockFlavor mockFlavor; + late GitHubService service; + + setUp(() { + mockDio = MockDio(); + mockFlavor = MockFlavor(); + + // GitHubService uses hardcoded URL, but factory takes flavor + service = GitHubService(mockDio, mockFlavor); + + when(mockDio.options).thenReturn(BaseOptions()); + }); + + test('getLatestRelease calls correct endpoint', () async { + final responseData = { + 'name': 'v2023.1.1', + 'body': 'Release notes...', + 'html_url': 'https://github.com/...', + }; + + when(mockDio.fetch>(any)).thenAnswer(( + invocation, + ) async { + return Response( + requestOptions: RequestOptions(path: ''), + data: responseData, + statusCode: 200, + ); + }); + + final result = await service.getLatestRelease(); + + expect(result, isA()); + expect(result.name, 'v2023.1.1'); + + verify( + mockDio.fetch>( + argThat( + predicate((options) { + return options.method == 'GET' && + options.path == + '/repos/tesla-android/android-raspberry-pi/releases/latest' && + options.baseUrl == 'https://api.github.com' && + options.headers['X-GitHub-Api-Version'] == '2022-11-28'; + }), + ), + ), + ).called(1); + }); + }); +} diff --git a/test/unit/services/health_service_test.dart b/test/unit/services/health_service_test.dart new file mode 100644 index 0000000..a95a45e --- /dev/null +++ b/test/unit/services/health_service_test.dart @@ -0,0 +1,85 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/common/network/health_service.dart'; + +import '../../helpers/mock_services.mocks.dart'; + +void main() { + group('HealthService', () { + late MockDio mockDio; + late MockFlavor mockFlavor; + late HealthService service; + + setUp(() { + mockDio = MockDio(); + mockFlavor = MockFlavor(); + + when( + mockFlavor.getString("configurationApiBaseUrl"), + ).thenReturn("http://localhost"); + + when(mockDio.options).thenReturn(BaseOptions()); + + service = HealthService(mockDio, mockFlavor); + }); + + test('getHealthCheck calls correct endpoint', () async { + when(mockDio.fetch(any)).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: ''), + data: null, + statusCode: 200, + ), + ); + + await service.getHealthCheck(); + + verify( + mockDio.fetch( + argThat( + predicate((options) { + return options.method == 'GET' && + options.path == '/health' && + options.baseUrl == 'http://localhost'; + }), + ), + ), + ).called(1); + }); + + test('getHealthCheck handles error response', () async { + when(mockDio.fetch(any)).thenThrow( + DioException( + requestOptions: RequestOptions(path: '/health'), + response: Response( + requestOptions: RequestOptions(path: '/health'), + statusCode: 503, + statusMessage: 'Service Unavailable', + ), + type: DioExceptionType.badResponse, + ), + ); + + expect( + () async => await service.getHealthCheck(), + throwsA(isA()), + ); + }); + + test('getHealthCheck handles network timeout', () async { + when(mockDio.fetch(any)).thenThrow( + DioException( + requestOptions: RequestOptions(path: '/health'), + type: DioExceptionType.connectionTimeout, + message: 'Connection timeout', + ), + ); + + expect( + () async => await service.getHealthCheck(), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/unit/view_model/base_settings_view_model_test.dart b/test/unit/view_model/base_settings_view_model_test.dart new file mode 100644 index 0000000..7d04cf5 --- /dev/null +++ b/test/unit/view_model/base_settings_view_model_test.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tesla_android/feature/settings/view_model/base_settings_view_model.dart'; + +// Test state classes +class TestState {} + +class TestStateLoading extends TestState {} + +class TestStateFetched extends TestState { + final String value; + TestStateFetched(this.value); +} + +class TestStateError extends TestState { + final String message; + TestStateError(this.message); +} + +// Concrete implementation for testing +class TestViewModel extends BaseSettingsViewModel { + @override + bool isLoadingState(TestState state) => state is TestStateLoading; + + @override + bool isFetchedState(TestState state) => state is TestStateFetched; + + @override + bool isErrorState(TestState state) => state is TestStateError; + + String? getValue(TestState state) { + if (state is TestStateFetched) { + return state.value; + } + return null; + } +} + +void main() { + group('BaseSettingsViewModel', () { + late TestViewModel viewModel; + + setUp(() { + viewModel = TestViewModel(); + }); + + group('state type checking', () { + test('isLoadingState returns true for loading state', () { + expect(viewModel.isLoadingState(TestStateLoading()), true); + expect(viewModel.isLoading(TestStateLoading()), true); + }); + + test('isLoadingState returns false for non-loading states', () { + expect(viewModel.isLoadingState(TestStateFetched('test')), false); + expect(viewModel.isLoadingState(TestStateError('error')), false); + }); + + test('isFetchedState returns true for fetched state', () { + expect(viewModel.isFetchedState(TestStateFetched('test')), true); + expect(viewModel.isFetched(TestStateFetched('test')), true); + }); + + test('isFetchedState returns false for non-fetched states', () { + expect(viewModel.isFetchedState(TestStateLoading()), false); + expect(viewModel.isFetchedState(TestStateError('error')), false); + }); + + test('isErrorState returns true for error state', () { + expect(viewModel.isErrorState(TestStateError('error')), true); + expect(viewModel.isError(TestStateError('error')), true); + }); + + test('isErrorState returns false for non-error states', () { + expect(viewModel.isErrorState(TestStateLoading()), false); + expect(viewModel.isErrorState(TestStateFetched('test')), false); + }); + }); + + group('buildStateWidget', () { + test('returns onFetched widget when state is fetched', () { + final widget = viewModel.buildStateWidget( + state: TestStateFetched('test'), + onFetched: () => const Text('Fetched'), + ); + + expect(widget, isA()); + expect((widget as Text).data, 'Fetched'); + }); + + test('returns custom onLoading widget when state is loading', () { + final widget = viewModel.buildStateWidget( + state: TestStateLoading(), + onFetched: () => const Text('Fetched'), + onLoading: () => const Text('Loading...'), + ); + + expect(widget, isA()); + expect((widget as Text).data, 'Loading...'); + }); + + test('returns CircularProgressIndicator by default for loading', () { + final widget = viewModel.buildStateWidget( + state: TestStateLoading(), + onFetched: () => const Text('Fetched'), + ); + + expect(widget, isA()); + }); + + test('returns custom onError widget when state is error', () { + final widget = viewModel.buildStateWidget( + state: TestStateError('Something went wrong'), + onFetched: () => const Text('Fetched'), + onError: () => const Text('Custom Error'), + ); + + expect(widget, isA()); + expect((widget as Text).data, 'Custom Error'); + }); + + test('returns default error text when no onError provided', () { + final widget = viewModel.buildStateWidget( + state: TestStateError('error'), + onFetched: () => const Text('Fetched'), + ); + + expect(widget, isA()); + expect((widget as Text).data, 'Service error'); + }); + + test('returns SizedBox.shrink for unknown states', () { + final widget = viewModel.buildStateWidget( + state: TestState(), // Base state, not recognized + onFetched: () => const Text('Fetched'), + ); + + expect(widget, isA()); + }); + }); + + group('concrete value extraction', () { + test('getValue returns value from fetched state', () { + expect(viewModel.getValue(TestStateFetched('hello')), 'hello'); + }); + + test('getValue returns null for non-fetched states', () { + expect(viewModel.getValue(TestStateLoading()), null); + expect(viewModel.getValue(TestStateError('error')), null); + }); + }); + }); +} diff --git a/test/unit/view_model/display_settings_view_model_test.dart b/test/unit/view_model/display_settings_view_model_test.dart new file mode 100644 index 0000000..83202b4 --- /dev/null +++ b/test/unit/view_model/display_settings_view_model_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tesla_android/feature/display/model/remote_display_state.dart'; +import 'package:tesla_android/feature/settings/bloc/display_configuration_state.dart'; +import 'package:tesla_android/feature/settings/view_model/display_settings_view_model.dart'; + +void main() { + group('DisplaySettingsViewModel', () { + late DisplaySettingsViewModel viewModel; + + setUp(() { + viewModel = DisplaySettingsViewModel(); + }); + + group('state checking methods', () { + test('isFetched returns true for SettingsFetched state', () { + final state = DisplayConfigurationStateSettingsFetched( + resolutionPreset: DisplayResolutionModePreset.res720p, + renderer: DisplayRendererType.mjpeg, + isResponsive: true, + quality: DisplayQualityPreset.quality80, + refreshRate: DisplayRefreshRatePreset.refresh30hz, + ); + + expect(viewModel.isFetched(state), true); + }); + + test('isLoading returns true for Loading state', () { + final state = DisplayConfigurationStateLoading(); + expect(viewModel.isLoading(state), true); + }); + + test('isLoading returns true for UpdateInProgress state', () { + final state = DisplayConfigurationStateSettingsUpdateInProgress(); + expect(viewModel.isLoading(state), true); + }); + + test('isError returns true for Error state', () { + final state = DisplayConfigurationStateError(); + expect(viewModel.isError(state), true); + }); + }); + + group('value extraction methods', () { + final fetchedState = DisplayConfigurationStateSettingsFetched( + resolutionPreset: DisplayResolutionModePreset.res720p, + renderer: DisplayRendererType.h264WebCodecs, + isResponsive: false, + quality: DisplayQualityPreset.quality60, + refreshRate: DisplayRefreshRatePreset.refresh60hz, + ); + + test('getRenderer extracts renderer from fetched state', () { + expect( + viewModel.getRenderer(fetchedState), + DisplayRendererType.h264WebCodecs, + ); + }); + + test('getResolutionPreset extracts resolution from fetched state', () { + expect( + viewModel.getResolutionPreset(fetchedState), + DisplayResolutionModePreset.res720p, + ); + }); + + test('getQualityPreset extracts quality from fetched state', () { + expect( + viewModel.getQualityPreset(fetchedState), + DisplayQualityPreset.quality60, + ); + }); + + test('getRefreshRate extracts refresh rate from fetched state', () { + expect( + viewModel.getRefreshRate(fetchedState), + DisplayRefreshRatePreset.refresh60hz, + ); + }); + + test('getResponsiveness extracts responsiveness from fetched state', () { + expect(viewModel.getResponsiveness(fetchedState), false); + }); + + test('getRenderer returns null for non-fetched state', () { + expect(viewModel.getRenderer(DisplayConfigurationStateLoading()), null); + }); + }); + + group('buildStateWidget', () { + test('calls onFetched for SettingsFetched state', () { + final state = DisplayConfigurationStateSettingsFetched( + resolutionPreset: DisplayResolutionModePreset.res720p, + renderer: DisplayRendererType.mjpeg, + isResponsive: true, + quality: DisplayQualityPreset.quality80, + refreshRate: DisplayRefreshRatePreset.refresh30hz, + ); + + bool onFetchedCalled = false; + viewModel.buildStateWidget( + state: state, + onFetched: () { + onFetchedCalled = true; + return Container(); + }, + ); + + expect(onFetchedCalled, true); + }); + }); + }); +} diff --git a/test/unit/view_model/hotspot_settings_view_model_test.dart b/test/unit/view_model/hotspot_settings_view_model_test.dart new file mode 100644 index 0000000..beafeae --- /dev/null +++ b/test/unit/view_model/hotspot_settings_view_model_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tesla_android/feature/settings/bloc/system_configuration_state.dart'; +import 'package:tesla_android/feature/settings/model/softap_band_type.dart'; +import 'package:tesla_android/feature/settings/model/system_configuration_response_body.dart'; +import 'package:tesla_android/feature/settings/view_model/hotspot_settings_view_model.dart'; + +void main() { + group('HotspotSettingsViewModel', () { + late HotspotSettingsViewModel viewModel; + + setUp(() { + viewModel = HotspotSettingsViewModel(); + }); + + final testConfig = SystemConfigurationResponseBody( + bandType: 1, + channel: 6, + channelWidth: 2, + isEnabledFlag: 1, + isOfflineModeEnabledFlag: 1, + isOfflineModeTelemetryEnabledFlag: 0, + isOfflineModeTeslaFirmwareDownloadsEnabledFlag: 1, + browserAudioIsEnabled: 1, + browserAudioVolume: 100, + isGPSEnabled: 1, + ); + + group('state checking methods', () { + test('hasSettings returns true for SettingsFetched state', () { + final state = SystemConfigurationStateSettingsFetched( + currentConfiguration: testConfig, + ); + expect(viewModel.hasSettings(state), true); + }); + + test('hasSettings returns true for SettingsModified state', () { + final state = SystemConfigurationStateSettingsModified( + currentConfiguration: testConfig, + newBandType: SoftApBandType.band2_4GHz, + isSoftApEnabled: true, + isOfflineModeEnabled: true, + isOfflineModeTelemetryEnabled: false, + isOfflineModeTeslaFirmwareDownloadsEnabled: true, + ); + expect(viewModel.hasSettings(state), true); + }); + + test('isFetchError returns true for FetchingError state', () { + final state = SystemConfigurationStateSettingsFetchingError(); + expect(viewModel.isFetchError(state), true); + }); + + test('isSaveError returns true for SavingFailedError state', () { + final state = SystemConfigurationStateSettingsSavingFailedError(); + expect(viewModel.isSaveError(state), true); + }); + }); + + group('value extraction from fetched state', () { + final fetchedState = SystemConfigurationStateSettingsFetched( + currentConfiguration: testConfig, + ); + + test('getSoftApBand extracts band from config', () { + final band = viewModel.getSoftApBand(fetchedState); + expect(band, isNotNull); + expect(band, SoftApBandType.band2_4GHz); + }); + + test('getOfflineModeEnabled converts flag to bool', () { + expect(viewModel.getOfflineModeEnabled(fetchedState), true); + }); + + test('getOfflineModeTelemetryEnabled converts flag to bool', () { + expect(viewModel.getOfflineModeTelemetryEnabled(fetchedState), false); + }); + + test('getTeslaFirmwareDownloadsEnabled converts flag to bool', () { + expect(viewModel.getTeslaFirmwareDownloadsEnabled(fetchedState), true); + }); + }); + + group('value extraction from modified state', () { + final modifiedState = SystemConfigurationStateSettingsModified( + currentConfiguration: testConfig, + newBandType: SoftApBandType.band5GHz36, + isSoftApEnabled: true, + isOfflineModeEnabled: false, + isOfflineModeTelemetryEnabled: true, + isOfflineModeTeslaFirmwareDownloadsEnabled: false, + ); + + test('getSoftApBand extracts band from modified state', () { + expect( + viewModel.getSoftApBand(modifiedState), + SoftApBandType.band5GHz36, + ); + }); + + test('getOfflineModeEnabled extracts from modified state', () { + expect(viewModel.getOfflineModeEnabled(modifiedState), false); + }); + + test('getOfflineModeTelemetryEnabled extracts from modified state', () { + expect(viewModel.getOfflineModeTelemetryEnabled(modifiedState), true); + }); + + test('getTeslaFirmwareDownloadsEnabled extracts from modified state', () { + expect( + viewModel.getTeslaFirmwareDownloadsEnabled(modifiedState), + false, + ); + }); + }); + }); +} diff --git a/test/unit/view_model/rear_display_settings_view_model_test.dart b/test/unit/view_model/rear_display_settings_view_model_test.dart new file mode 100644 index 0000000..45ad1dc --- /dev/null +++ b/test/unit/view_model/rear_display_settings_view_model_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tesla_android/feature/settings/bloc/rear_display_configuration_state.dart'; +import 'package:tesla_android/feature/settings/view_model/rear_display_settings_view_model.dart'; + +void main() { + group('RearDisplaySettingsViewModel', () { + late RearDisplaySettingsViewModel viewModel; + + setUp(() { + viewModel = RearDisplaySettingsViewModel(); + }); + + group('state checking methods', () { + test('isFetched returns true for SettingsFetched state', () { + final state = RearDisplayConfigurationStateSettingsFetched( + isRearDisplayEnabled: true, + isRearDisplayPrioritised: false, + isCurrentDisplayPrimary: true, + ); + expect(viewModel.isFetched(state), true); + }); + + test('isLoading returns true for Loading state', () { + final state = RearDisplayConfigurationStateLoading(); + expect(viewModel.isLoading(state), true); + }); + + test('isLoading returns true for UpdateInProgress state', () { + final state = RearDisplayConfigurationStateSettingsUpdateInProgress(); + expect(viewModel.isLoading(state), true); + }); + + test('isError returns true for Error state', () { + final state = RearDisplayConfigurationStateError(); + expect(viewModel.isError(state), true); + }); + }); + + group('value extraction methods', () { + final fetchedState = RearDisplayConfigurationStateSettingsFetched( + isRearDisplayEnabled: true, + isRearDisplayPrioritised: false, + isCurrentDisplayPrimary: true, + ); + + test( + 'getRearDisplayEnabled extracts enabled status from fetched state', + () { + expect(viewModel.getRearDisplayEnabled(fetchedState), true); + }, + ); + + test( + 'getRearDisplayPrioritised extracts priority from fetched state', + () { + expect(viewModel.getRearDisplayPrioritised(fetchedState), false); + }, + ); + + test( + 'getCurrentDisplayPrimary extracts primary status from fetched state', + () { + expect(viewModel.getCurrentDisplayPrimary(fetchedState), true); + }, + ); + + test('getRearDisplayEnabled returns null for non-fetched state', () { + expect( + viewModel.getRearDisplayEnabled( + RearDisplayConfigurationStateLoading(), + ), + null, + ); + }); + }); + + group('buildStateWidget', () { + test('calls onFetched for SettingsFetched state', () { + final state = RearDisplayConfigurationStateSettingsFetched( + isRearDisplayEnabled: true, + isRearDisplayPrioritised: false, + isCurrentDisplayPrimary: true, + ); + + bool onFetchedCalled = false; + viewModel.buildStateWidget( + state: state, + onFetched: () { + onFetchedCalled = true; + return const SizedBox.shrink(); + }, + ); + + expect(onFetchedCalled, true); + }); + }); + }); +} diff --git a/test/unit/view_model/sound_settings_view_model_test.dart b/test/unit/view_model/sound_settings_view_model_test.dart new file mode 100644 index 0000000..ea37dba --- /dev/null +++ b/test/unit/view_model/sound_settings_view_model_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tesla_android/feature/settings/bloc/audio_configuration_state.dart'; +import 'package:tesla_android/feature/settings/view_model/sound_settings_view_model.dart'; + +void main() { + group('SoundSettingsViewModel', () { + late SoundSettingsViewModel viewModel; + + setUp(() { + viewModel = SoundSettingsViewModel(); + }); + + group('state checking methods', () { + test('isFetched returns true for SettingsFetched state', () { + final state = AudioConfigurationStateSettingsFetched( + isEnabled: true, + volume: 50, + ); + expect(viewModel.isFetched(state), true); + }); + + test('isLoading returns true for Loading state', () { + final state = AudioConfigurationStateLoading(); + expect(viewModel.isLoading(state), true); + }); + + test('isLoading returns true for UpdateInProgress state', () { + final state = AudioConfigurationStateSettingsUpdateInProgress(); + expect(viewModel.isLoading(state), true); + }); + + test('isError returns true for Error state', () { + final state = AudioConfigurationStateError(); + expect(viewModel.isError(state), true); + }); + }); + + group('value extraction methods', () { + final fetchedState = AudioConfigurationStateSettingsFetched( + isEnabled: true, + volume: 75, + ); + + test('getAudioEnabled extracts enabled status from fetched state', () { + expect(viewModel.getAudioEnabled(fetchedState), true); + }); + + test('getVolume extracts volume from fetched state', () { + expect(viewModel.getVolume(fetchedState), 75); + }); + + test('getAudioEnabled returns null for non-fetched state', () { + expect( + viewModel.getAudioEnabled(AudioConfigurationStateLoading()), + null, + ); + }); + + test('getVolume returns null for non-fetched state', () { + expect(viewModel.getVolume(AudioConfigurationStateLoading()), null); + }); + }); + + group('buildStateWidget', () { + test('calls onFetched for SettingsFetched state', () { + final state = AudioConfigurationStateSettingsFetched( + isEnabled: true, + volume: 50, + ); + + bool onFetchedCalled = false; + viewModel.buildStateWidget( + state: state, + onFetched: () { + onFetchedCalled = true; + return const SizedBox.shrink(); + }, + ); + + expect(onFetchedCalled, true); + }); + }); + }); +} diff --git a/test/widget/components/settings_dropdown_test.dart b/test/widget/components/settings_dropdown_test.dart new file mode 100644 index 0000000..07fdbd3 --- /dev/null +++ b/test/widget/components/settings_dropdown_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tesla_android/common/ui/components/settings_dropdown.dart'; + +enum TestEnum { + optionA, + optionB; + + String name() => toString().split('.').last; +} + +void main() { + group('SettingsDropdown', () { + testWidgets('renders correctly with given items', ( + WidgetTester tester, + ) async { + TestEnum? selectedValue = TestEnum.optionA; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + return SettingsDropdown( + value: selectedValue!, + items: TestEnum.values, + onChanged: (value) { + setState(() { + selectedValue = value; + }); + }, + itemLabel: (item) => item.name(), + ); + }, + ), + ), + ), + ); + + expect(find.text('optionA'), findsOneWidget); + expect(find.byType(DropdownButton), findsOneWidget); + + await tester.tap(find.text('optionA')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('optionB').last); + await tester.pumpAndSettle(); + + expect(selectedValue, TestEnum.optionB); + expect(find.text('optionB'), findsOneWidget); + }); + + test('DisplayRendererTypeExt extension works', () { + // Valid enum with name() method + expect(TestEnum.optionA.displayName(), 'optionA'); + + // Plain object + final obj = Object(); + // The extension is on Object, so any object has .displayName() + // But wait, the extension is defined in the file but not exported? + // It seems to be accessible if imported. + + expect(obj.displayName(), obj.toString()); + }); + }); +} diff --git a/test/widget/donations/donation_page_test.dart b/test/widget/donations/donation_page_test.dart new file mode 100644 index 0000000..ed7d746 --- /dev/null +++ b/test/widget/donations/donation_page_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tesla_android/feature/donations/widget/donation_page.dart'; + +void main() { + group('DonationPage Widget', () { + testWidgets('renders donation page with title', (tester) async { + await tester.pumpWidget(const MaterialApp(home: DonationPage())); + + expect(find.text('Donations'), findsWidgets); + }); + + testWidgets('displays donation message', (tester) async { + await tester.pumpWidget(const MaterialApp(home: DonationPage())); + + expect(find.textContaining('Thank you for considering'), findsOneWidget); + expect(find.textContaining('community-founded project'), findsOneWidget); + }); + + testWidgets('displays QR code', (tester) async { + await tester.pumpWidget(const MaterialApp(home: DonationPage())); + + await tester.pumpAndSettle(); + + // QR code widget should be present + expect(find.byType(DonationPage), findsOneWidget); + }); + }); +} diff --git a/test/widget/home/audio_button_test.dart b/test/widget/home/audio_button_test.dart new file mode 100644 index 0000000..3f0794b --- /dev/null +++ b/test/widget/home/audio_button_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/common/service/audio_service.dart'; +import 'package:tesla_android/feature/home/widget/audio_button.dart'; +import 'package:tesla_android/feature/settings/bloc/audio_configuration_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/audio_configuration_state.dart'; + +import '../../helpers/mock_cubits.mocks.dart'; + +void main() { + late MockAudioConfigurationCubit mockCubit; + late MockAudioService mockAudioService; + + setUp(() { + mockCubit = MockAudioConfigurationCubit(); + mockAudioService = MockAudioService(); + + GetIt.I.registerSingleton(mockAudioService); + when(mockCubit.fetchConfiguration()).thenAnswer((_) async {}); + }); + + tearDown(() { + GetIt.I.reset(); + }); + + Widget makeTestableWidget(Widget child) { + return MaterialApp( + home: Scaffold( + body: BlocProvider.value( + value: mockCubit, + child: child, + ), + ), + ); + } + + group('AudioButton Widget', () { + testWidgets('shows nothing when audio is disabled', (tester) async { + final state = AudioConfigurationStateSettingsFetched( + isEnabled: false, + volume: 50, + ); + when(mockCubit.state).thenReturn(state); + when(mockCubit.stream).thenAnswer((_) => Stream.value(state)); + when(mockAudioService.getAudioState()).thenReturn('stopped'); + when(mockAudioService.addAudioStateListener(any)).thenReturn(() {}); + + await tester.pumpWidget(makeTestableWidget(const AudioButton())); + await tester.pumpAndSettle(); + + expect(find.byType(IconButton), findsNothing); + }); + + testWidgets('shows icon when audio is enabled', (tester) async { + final state = AudioConfigurationStateSettingsFetched( + isEnabled: true, + volume: 50, + ); + when(mockCubit.state).thenReturn(state); + when(mockCubit.stream).thenAnswer((_) => Stream.value(state)); + when(mockAudioService.getAudioState()).thenReturn('stopped'); + when(mockAudioService.addAudioStateListener(any)).thenReturn(() {}); + + await tester.pumpWidget(makeTestableWidget(const AudioButton())); + await tester.pumpAndSettle(); + + expect(find.byType(IconButton), findsOneWidget); + expect(find.byIcon(Icons.volume_off), findsOneWidget); + }); + + testWidgets('toggles audio state on press', (tester) async { + final state = AudioConfigurationStateSettingsFetched( + isEnabled: true, + volume: 50, + ); + when(mockCubit.state).thenReturn(state); + when(mockCubit.stream).thenAnswer((_) => Stream.value(state)); + when(mockAudioService.getAudioState()).thenReturn('stopped'); + when(mockAudioService.addAudioStateListener(any)).thenReturn(() {}); + + await tester.pumpWidget(makeTestableWidget(const AudioButton())); + await tester.pumpAndSettle(); + + // Tap to start + await tester.tap(find.byType(IconButton)); + await tester.pumpAndSettle(); + + verify(mockAudioService.startAudioFromGesture()).called(1); + expect(find.byIcon(Icons.volume_up), findsOneWidget); + + // Tap to stop + await tester.tap(find.byType(IconButton)); + await tester.pumpAndSettle(); + + verify(mockAudioService.stopAudio()).called(1); + expect(find.byIcon(Icons.volume_off), findsOneWidget); + }); + }); +} diff --git a/test/widget/home/home_page_test.dart b/test/widget/home/home_page_test.dart new file mode 100644 index 0000000..27e5245 --- /dev/null +++ b/test/widget/home/home_page_test.dart @@ -0,0 +1,386 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/feature/connectivityCheck/cubit/connectivity_check_cubit.dart'; +import 'package:tesla_android/feature/connectivityCheck/model/connectivity_state.dart'; +import 'package:tesla_android/feature/display/cubit/display_cubit.dart'; +import 'package:tesla_android/feature/display/cubit/display_state.dart'; +import 'package:tesla_android/feature/display/model/remote_display_state.dart'; +import 'package:tesla_android/feature/home/cubit/ota_update_cubit.dart'; +import 'package:tesla_android/feature/home/cubit/ota_update_state.dart'; +import 'package:tesla_android/feature/home/home_page.dart'; +import 'package:tesla_android/feature/home/widget/audio_button.dart'; +import 'package:tesla_android/feature/home/widget/settings_button.dart'; +import 'package:tesla_android/feature/home/widget/update_button.dart'; +import 'package:tesla_android/feature/settings/bloc/audio_configuration_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/audio_configuration_state.dart'; +import 'package:tesla_android/feature/touchscreen/cubit/touchscreen_cubit.dart'; +import 'package:tesla_android/common/service/audio_service.dart'; + +import '../../helpers/cubit_builders.dart'; +import '../../helpers/mock_cubits.mocks.dart'; + +void main() { + late MockConnectivityCheckCubit mockConnectivityCheckCubit; + late MockDisplayCubit mockDisplayCubit; + late MockAudioConfigurationCubit mockAudioConfigurationCubit; + late MockOTAUpdateCubit mockOTAUpdateCubit; + + late MockTouchscreenCubit mockTouchscreenCubit; + late MockAudioService mockAudioService; + + setUp(() { + // Use CubitBuilders for consistent mock setup + mockConnectivityCheckCubit = CubitBuilders.buildConnectivityCheckCubit(); + mockDisplayCubit = CubitBuilders.buildDisplayCubit(); + mockAudioConfigurationCubit = CubitBuilders.buildAudioConfigurationCubit(); + mockOTAUpdateCubit = CubitBuilders.buildOTAUpdateCubit(); + mockTouchscreenCubit = CubitBuilders.buildTouchscreenCubit(); + + mockAudioService = MockAudioService(); + when(mockAudioService.getAudioState()).thenReturn('stopped'); + when(mockAudioService.addAudioStateListener(any)).thenReturn(() {}); + + GetIt.I.registerSingleton( + mockConnectivityCheckCubit, + ); + GetIt.I.registerSingleton(mockDisplayCubit); + GetIt.I.registerSingleton( + mockAudioConfigurationCubit, + ); + GetIt.I.registerSingleton(mockOTAUpdateCubit); + GetIt.I.registerSingleton(mockTouchscreenCubit); + GetIt.I.registerSingleton(mockAudioService); + }); + + tearDown(() { + GetIt.I.reset(); + }); + + Widget createWidgetUnderTest() { + return MaterialApp( + home: MultiBlocProvider( + providers: [ + BlocProvider.value( + value: mockConnectivityCheckCubit, + ), + BlocProvider.value(value: mockDisplayCubit), + BlocProvider.value( + value: mockAudioConfigurationCubit, + ), + BlocProvider.value(value: mockOTAUpdateCubit), + BlocProvider.value(value: mockTouchscreenCubit), + ], + child: HomePage(), + ), + ); + } + + testWidgets('HomePage renders DisplayView when backend is accessible', ( + tester, + ) async { + // CubitBuilders already set up defaults: backendAccessible, DisplayStateNormal, etc. + // Only override if we need different values + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(); + + expect(find.byType(HomePage), findsOneWidget); + expect(find.byType(AudioButton), findsOneWidget); + expect(find.byType(UpdateButton), findsOneWidget); + expect(find.byType(SettingsButton), findsOneWidget); + // DisplayView is stubbed in VM tests, but we can check if it's in the tree + // The stub returns a Center with Text("DisplayView is not supported on this platform") + expect( + find.text("DisplayView is not supported on this platform"), + findsOneWidget, + ); + }); + + testWidgets('HomePage renders error icon when backend is unreachable', ( + tester, + ) async { + when( + mockConnectivityCheckCubit.state, + ).thenReturn(ConnectivityState.backendUnreachable); + when( + mockConnectivityCheckCubit.stream, + ).thenAnswer((_) => const Stream.empty()); + + when(mockDisplayCubit.state).thenReturn(DisplayStateInitial()); + when(mockDisplayCubit.stream).thenAnswer((_) => const Stream.empty()); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pump(); // pump once to rebuild + + expect(find.byIcon(Icons.error_outline), findsOneWidget); + expect(find.byType(AudioButton), findsNothing); + }); + + testWidgets('HomePage shows display type selection dialog when triggered', ( + tester, + ) async { + tester.view.physicalSize = const Size(1280, 720); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + tester.view.physicalSize = const Size(1280, 720); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + when( + mockConnectivityCheckCubit.state, + ).thenReturn(ConnectivityState.backendAccessible); + when( + mockConnectivityCheckCubit.stream, + ).thenAnswer((_) => const Stream.empty()); + + // Start with initial state + when(mockDisplayCubit.state).thenReturn( + DisplayStateNormal( + viewSize: const Size(1280, 720), + adjustedSize: const Size(1280, 720), + rendererType: DisplayRendererType.mjpeg, + ), + ); + + // Create a controller to simulate stream events + final displayStateController = StreamController.broadcast(); + when( + mockDisplayCubit.stream, + ).thenAnswer((_) => displayStateController.stream); + + when(mockAudioConfigurationCubit.state).thenReturn( + AudioConfigurationStateSettingsFetched(isEnabled: true, volume: 100), + ); + when( + mockAudioConfigurationCubit.stream, + ).thenAnswer((_) => const Stream.empty()); + when( + mockAudioConfigurationCubit.fetchConfiguration(), + ).thenAnswer((_) async {}); + + when(mockOTAUpdateCubit.state).thenReturn(OTAUpdateStateInitial()); + when(mockOTAUpdateCubit.stream).thenAnswer((_) => const Stream.empty()); + + when(mockTouchscreenCubit.state).thenReturn(false); + when(mockTouchscreenCubit.stream).thenAnswer((_) => const Stream.empty()); + + when(mockDisplayCubit.onWindowSizeChanged(any)).thenAnswer((_) async {}); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pump(); + await tester.pump(); + + // Emit the trigger state + when( + mockDisplayCubit.state, + ).thenReturn(DisplayStateDisplayTypeSelectionTriggered()); + displayStateController.add(DisplayStateDisplayTypeSelectionTriggered()); + + // Emit normal state to stop CircularProgressIndicator and allow pumpAndSettle + when(mockDisplayCubit.state).thenReturn( + DisplayStateNormal( + viewSize: const Size(1280, 720), + adjustedSize: const Size(1280, 720), + rendererType: DisplayRendererType.mjpeg, + ), + ); + displayStateController.add( + DisplayStateNormal( + viewSize: const Size(1280, 720), + adjustedSize: const Size(1280, 720), + rendererType: DisplayRendererType.mjpeg, + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Display Type'), findsOneWidget); + expect(find.text('MAIN DISPLAY'), findsOneWidget); + expect(find.text('REAR DISPLAY'), findsOneWidget); + + await displayStateController.close(); + }); + + testWidgets('HomePage dialog - MAIN DISPLAY button calls cubit correctly', ( + tester, + ) async { + tester.view.physicalSize = const Size(1280, 720); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + when( + mockConnectivityCheckCubit.state, + ).thenReturn(ConnectivityState.backendAccessible); + when( + mockConnectivityCheckCubit.stream, + ).thenAnswer((_) => const Stream.empty()); + + when(mockDisplayCubit.state).thenReturn( + DisplayStateNormal( + viewSize: const Size(1280, 720), + adjustedSize: const Size(1280, 720), + rendererType: DisplayRendererType.mjpeg, + ), + ); + + final displayStateController = StreamController.broadcast(); + when( + mockDisplayCubit.stream, + ).thenAnswer((_) => displayStateController.stream); + when(mockDisplayCubit.onWindowSizeChanged(any)).thenAnswer((_) async {}); + when( + mockDisplayCubit.onDisplayTypeSelectionFinished( + isPrimaryDisplay: anyNamed('isPrimaryDisplay'), + ), + ).thenAnswer((_) async {}); + + when(mockAudioConfigurationCubit.state).thenReturn( + AudioConfigurationStateSettingsFetched(isEnabled: true, volume: 100), + ); + when( + mockAudioConfigurationCubit.stream, + ).thenAnswer((_) => const Stream.empty()); + + when(mockOTAUpdateCubit.state).thenReturn(OTAUpdateStateInitial()); + when(mockOTAUpdateCubit.stream).thenAnswer((_) => const Stream.empty()); + + when(mockTouchscreenCubit.state).thenReturn(false); + when(mockTouchscreenCubit.stream).thenAnswer((_) => const Stream.empty()); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pump(); + + // Trigger dialog + when( + mockDisplayCubit.state, + ).thenReturn(DisplayStateDisplayTypeSelectionTriggered()); + displayStateController.add(DisplayStateDisplayTypeSelectionTriggered()); + + when(mockDisplayCubit.state).thenReturn( + DisplayStateNormal( + viewSize: const Size(1280, 720), + adjustedSize: const Size(1280, 720), + rendererType: DisplayRendererType.mjpeg, + ), + ); + displayStateController.add( + DisplayStateNormal( + viewSize: const Size(1280, 720), + adjustedSize: const Size(1280, 720), + rendererType: DisplayRendererType.mjpeg, + ), + ); + + await tester.pumpAndSettle(); + + // Tap MAIN DISPLAY button + await tester.tap(find.text('MAIN DISPLAY')); + await tester.pumpAndSettle(); + + // Verify cubit was called with isPrimaryDisplay: true + verify( + mockDisplayCubit.onDisplayTypeSelectionFinished(isPrimaryDisplay: true), + ).called(1); + + // Verify dialog closed + expect(find.text('Display Type'), findsNothing); + + await displayStateController.close(); + }); + + testWidgets('HomePage dialog - REAR DISPLAY button calls cubit correctly', ( + tester, + ) async { + tester.view.physicalSize = const Size(1280, 720); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + when( + mockConnectivityCheckCubit.state, + ).thenReturn(ConnectivityState.backendAccessible); + when( + mockConnectivityCheckCubit.stream, + ).thenAnswer((_) => const Stream.empty()); + + when(mockDisplayCubit.state).thenReturn( + DisplayStateNormal( + viewSize: const Size(1280, 720), + adjustedSize: const Size(1280, 720), + rendererType: DisplayRendererType.mjpeg, + ), + ); + + final displayStateController = StreamController.broadcast(); + when( + mockDisplayCubit.stream, + ).thenAnswer((_) => displayStateController.stream); + when(mockDisplayCubit.onWindowSizeChanged(any)).thenAnswer((_) async {}); + when( + mockDisplayCubit.onDisplayTypeSelectionFinished( + isPrimaryDisplay: anyNamed('isPrimaryDisplay'), + ), + ).thenAnswer((_) async {}); + + when(mockAudioConfigurationCubit.state).thenReturn( + AudioConfigurationStateSettingsFetched(isEnabled: true, volume: 100), + ); + when( + mockAudioConfigurationCubit.stream, + ).thenAnswer((_) => const Stream.empty()); + + when(mockOTAUpdateCubit.state).thenReturn(OTAUpdateStateInitial()); + when(mockOTAUpdateCubit.stream).thenAnswer((_) => const Stream.empty()); + + when(mockTouchscreenCubit.state).thenReturn(false); + when(mockTouchscreenCubit.stream).thenAnswer((_) => const Stream.empty()); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pump(); + + // Trigger dialog + when( + mockDisplayCubit.state, + ).thenReturn(DisplayStateDisplayTypeSelectionTriggered()); + displayStateController.add(DisplayStateDisplayTypeSelectionTriggered()); + + when(mockDisplayCubit.state).thenReturn( + DisplayStateNormal( + viewSize: const Size(1280, 720), + adjustedSize: const Size(1280, 720), + rendererType: DisplayRendererType.mjpeg, + ), + ); + displayStateController.add( + DisplayStateNormal( + viewSize: const Size(1280, 720), + adjustedSize: const Size(1280, 720), + rendererType: DisplayRendererType.mjpeg, + ), + ); + + await tester.pumpAndSettle(); + + // Tap REAR DISPLAY button + await tester.tap(find.text('REAR DISPLAY')); + await tester.pumpAndSettle(); + + // Verify cubit was called with isPrimaryDisplay: false + verify( + mockDisplayCubit.onDisplayTypeSelectionFinished(isPrimaryDisplay: false), + ).called(1); + + // Verify dialog closed + expect(find.text('Display Type'), findsNothing); + + await displayStateController.close(); + }); +} diff --git a/test/widget/home/settings_button_test.dart b/test/widget/home/settings_button_test.dart new file mode 100644 index 0000000..f61a999 --- /dev/null +++ b/test/widget/home/settings_button_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:tesla_android/common/navigation/ta_page_factory.dart'; +import 'package:tesla_android/feature/home/widget/settings_button.dart'; + +void main() { + setUp(() { + final pageFactory = TAPageFactory(); + GetIt.I.registerSingleton(pageFactory); + }); + + tearDown(() { + GetIt.I.reset(); + }); + + group('SettingsButton Widget', () { + testWidgets('renders IconButton with settings icon', (tester) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: SettingsButton())), + ); + + expect(find.byType(IconButton), findsOneWidget); + expect(find.byIcon(Icons.settings), findsOneWidget); + }); + + testWidgets('triggers navigation on tap', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: const Scaffold(body: SettingsButton()), + routes: {'/about': (context) => const Scaffold(body: Text('About'))}, + ), + ); + + await tester.tap(find.byType(IconButton)); + await tester.pumpAndSettle(); + + expect(find.text('About'), findsOneWidget); + }); + }); +} diff --git a/test/widget/home/update_button_test.dart b/test/widget/home/update_button_test.dart new file mode 100644 index 0000000..220a91b --- /dev/null +++ b/test/widget/home/update_button_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/feature/home/cubit/ota_update_cubit.dart'; +import 'package:tesla_android/feature/home/cubit/ota_update_state.dart'; +import 'package:tesla_android/feature/home/widget/update_button.dart'; + +import '../../helpers/cubit_builders.dart'; +import '../../helpers/mock_cubits.mocks.dart'; + +void main() { + late MockOTAUpdateCubit mockOTAUpdateCubit; + + setUp(() { + mockOTAUpdateCubit = CubitBuilders.buildOTAUpdateCubit(); + }); + + Widget buildTestWidget() { + return MaterialApp( + home: Scaffold( + body: BlocProvider( + create: (_) => mockOTAUpdateCubit, + child: const UpdateButton(), + ), + ), + ); + } + + testWidgets('UpdateButton renders nothing when state is not Available', ( + WidgetTester tester, + ) async { + when(mockOTAUpdateCubit.state).thenReturn(OTAUpdateStateInitial()); + + await tester.pumpWidget(buildTestWidget()); + + expect(find.byType(IconButton), findsNothing); + expect(find.byType(SizedBox), findsOneWidget); + }); + + testWidgets('UpdateButton renders button when state is Available', ( + WidgetTester tester, + ) async { + when(mockOTAUpdateCubit.state).thenReturn(OTAUpdateStateAvailable()); + + await tester.pumpWidget(buildTestWidget()); + + expect(find.byType(IconButton), findsOneWidget); + expect(find.byIcon(Icons.download_rounded), findsOneWidget); + }); + + testWidgets('UpdateButton calls launchUpdater on tap', ( + WidgetTester tester, + ) async { + when(mockOTAUpdateCubit.state).thenReturn(OTAUpdateStateAvailable()); + + await tester.pumpWidget(buildTestWidget()); + + await tester.tap(find.byType(IconButton)); + await tester.pump(); + + verify(mockOTAUpdateCubit.launchUpdater()).called(1); + }); +} diff --git a/test/widget/navigation/ta_navigator_test.dart b/test/widget/navigation/ta_navigator_test.dart new file mode 100644 index 0000000..b32711f --- /dev/null +++ b/test/widget/navigation/ta_navigator_test.dart @@ -0,0 +1,211 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/common/navigation/ta_navigator.dart'; +import 'package:tesla_android/common/navigation/ta_page.dart'; +import 'package:tesla_android/common/navigation/ta_page_factory.dart'; +import 'package:tesla_android/common/navigation/ta_page_type.dart'; + +class MockTAPageFactory extends Mock implements TAPageFactory { + @override + WidgetBuilder buildPage(TAPage? page) { + return super.noSuchMethod( + Invocation.method(#buildPage, [page]), + returnValue: (BuildContext context) => Container(), + returnValueForMissingStub: (BuildContext context) => Container(), + ); + } +} + +class MockNavigatorObserver extends Mock implements NavigatorObserver {} + +void main() { + late MockTAPageFactory mockPageFactory; + late NavigatorObserver mockObserver; + + setUp(() { + mockPageFactory = MockTAPageFactory(); + mockObserver = MockNavigatorObserver(); + + GetIt.I.registerSingleton(mockPageFactory); + + when( + mockPageFactory.buildPage(any), + ).thenReturn((context) => Container(key: const Key('target_page'))); + }); + + tearDown(() { + GetIt.I.reset(); + }); + + Widget makeTestableWidget(Widget child) { + return MaterialApp( + home: child, + navigatorObservers: [mockObserver], + routes: { + '/test_route': (context) => Container(key: const Key('pushed_page')), + }, + ); + } + + group('TANavigator', () { + testWidgets('push standard page calls pushNamed', (tester) async { + const testPage = TAPage( + title: 'Test', + route: '/test_route', + type: TAPageType.standard, + ); + + await tester.pumpWidget( + makeTestableWidget( + Builder( + builder: (context) { + return ElevatedButton( + onPressed: () => + TANavigator.push(context: context, page: testPage), + child: const Text('Push'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Push')); + await tester.pumpAndSettle(); + + // verify(mockObserver.didPush(any, any)); + expect(find.byKey(const Key('pushed_page')), findsOneWidget); + }); + + testWidgets('push dialog page calls showDialog', (tester) async { + const dialogPage = TAPage( + title: 'Dialog', + route: '/dialog', + type: TAPageType.dialog, + ); + + await tester.pumpWidget( + makeTestableWidget( + Builder( + builder: (context) { + return ElevatedButton( + onPressed: () => + TANavigator.push(context: context, page: dialogPage), + child: const Text('Dialog'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Dialog')); + await tester.pumpAndSettle(); + + verify(mockPageFactory.buildPage(dialogPage)); + expect(find.byKey(const Key('target_page')), findsOneWidget); + }); + + testWidgets('pushReplacement animated calls pushReplacementNamed', ( + tester, + ) async { + const testPage = TAPage( + title: 'Test', + route: '/test_route', + type: TAPageType.standard, + ); + + await tester.pumpWidget( + makeTestableWidget( + Builder( + builder: (context) { + return ElevatedButton( + onPressed: () => TANavigator.pushReplacement( + context: context, + page: testPage, + animated: true, + ), + child: const Text('Replace'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Replace')); + await tester.pumpAndSettle(); + + // verify(mockObserver.didReplace(newRoute: anyNamed('newRoute'), oldRoute: anyNamed('oldRoute'))); + expect(find.byKey(const Key('pushed_page')), findsOneWidget); + }); + + testWidgets( + 'pushReplacement non-animated calls pushReplacement with PageRouteBuilder', + (tester) async { + const testPage = TAPage( + title: 'Test', + route: '/test_route', + type: TAPageType.standard, + ); + + await tester.pumpWidget( + makeTestableWidget( + Builder( + builder: (context) { + return ElevatedButton( + onPressed: () => TANavigator.pushReplacement( + context: context, + page: testPage, + animated: false, + ), + child: const Text('Replace'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Replace')); + await tester.pumpAndSettle(); + + // verify(mockObserver.didReplace(newRoute: anyNamed('newRoute'), oldRoute: anyNamed('oldRoute'))); + verify(mockPageFactory.buildPage(testPage)); + expect(find.byKey(const Key('target_page')), findsOneWidget); + }, + ); + + testWidgets('pop calls navigator pop', (tester) async { + await tester.pumpWidget( + makeTestableWidget( + Builder( + builder: (context) { + return ElevatedButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) { + return ElevatedButton( + onPressed: () => TANavigator.pop(context: context), + child: const Text('Pop'), + ); + }, + ), + ); + }, + child: const Text('Push'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Push')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Pop')); + await tester.pumpAndSettle(); + + // verify(mockObserver.didPop(any, any)); + }); + }); +} diff --git a/test/widget/release_notes/release_notes_page_test.dart b/test/widget/release_notes/release_notes_page_test.dart new file mode 100644 index 0000000..0f202a0 --- /dev/null +++ b/test/widget/release_notes/release_notes_page_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/feature/releaseNotes/cubit/release_notes_cubit.dart'; +import 'package:tesla_android/feature/releaseNotes/cubit/release_notes_state.dart'; +import 'package:tesla_android/feature/releaseNotes/model/changelog_item.dart'; +import 'package:tesla_android/feature/releaseNotes/model/release_notes.dart'; +import 'package:tesla_android/feature/releaseNotes/model/version.dart'; +import 'package:tesla_android/feature/releaseNotes/widget/release_notes_page.dart'; + +import '../../helpers/mock_cubits.mocks.dart'; + +void main() { + late MockReleaseNotesCubit mockReleaseNotesCubit; + + setUp(() { + mockReleaseNotesCubit = MockReleaseNotesCubit(); + }); + + Widget createWidgetUnderTest() { + return MaterialApp( + home: BlocProvider.value( + value: mockReleaseNotesCubit, + child: const ReleaseNotesPage(), + ), + ); + } + + testWidgets('ReleaseNotesPage shows loading indicator when loading', ( + tester, + ) async { + when(mockReleaseNotesCubit.state).thenReturn(ReleaseNotesStateLoading()); + when(mockReleaseNotesCubit.stream).thenAnswer((_) => const Stream.empty()); + when(mockReleaseNotesCubit.loadReleaseNotes()).thenAnswer((_) async {}); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('ReleaseNotesPage shows error message when unavailable', ( + tester, + ) async { + when( + mockReleaseNotesCubit.state, + ).thenReturn(ReleaseNotesStateUnavailable()); + when(mockReleaseNotesCubit.stream).thenAnswer((_) => const Stream.empty()); + when(mockReleaseNotesCubit.loadReleaseNotes()).thenAnswer((_) async {}); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pump(); + + expect( + find.text('Error loading release notes. Please try again later.'), + findsOneWidget, + ); + }); + + testWidgets('ReleaseNotesPage shows release notes when loaded', ( + tester, + ) async { + final testReleaseNotes = ReleaseNotes( + versions: [ + Version( + versionName: '1.0.0', + changelogItems: [ + ChangelogItem( + title: 'Features', + shortDescription: 'Test feature', + descriptionMarkdown: '# Test feature\n\nThis is a test feature.', + ), + ], + ), + ], + ); + + when( + mockReleaseNotesCubit.state, + ).thenReturn(ReleaseNotesStateLoaded(releaseNotes: testReleaseNotes)); + when(mockReleaseNotesCubit.stream).thenAnswer((_) => const Stream.empty()); + when(mockReleaseNotesCubit.loadReleaseNotes()).thenAnswer((_) async {}); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(); + + expect(find.text('1.0.0'), findsWidgets); + expect(find.text('Features'), findsOneWidget); + }); +} diff --git a/test/widget/settings/display_settings_test.dart b/test/widget/settings/display_settings_test.dart new file mode 100644 index 0000000..25439c4 --- /dev/null +++ b/test/widget/settings/display_settings_test.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/feature/display/model/remote_display_state.dart'; +import 'package:tesla_android/feature/settings/bloc/display_configuration_state.dart'; +import 'package:tesla_android/feature/settings/widget/display_settings.dart'; + +import '../../helpers/mock_cubits.mocks.dart'; +import '../../helpers/settings_test_helpers.dart'; + +void main() { + late MockDisplayConfigurationCubit mockCubit; + + setUp(() { + mockCubit = MockDisplayConfigurationCubit(); + }); + + group('DisplaySettings Widget', () { + testWidgets('shows loading indicator when state is Loading', ( + tester, + ) async { + when(mockCubit.state).thenReturn(DisplayConfigurationStateLoading()); + when( + mockCubit.stream, + ).thenAnswer((_) => Stream.value(DisplayConfigurationStateLoading())); + + await tester.pumpWidget( + SettingsTestHelpers.buildSettingsWidget( + displayCubit: mockCubit, + child: const DisplaySettings(), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsWidgets); + }); + + testWidgets('shows error text when error state', (tester) async { + when(mockCubit.state).thenReturn(DisplayConfigurationStateError()); + when( + mockCubit.stream, + ).thenAnswer((_) => Stream.value(DisplayConfigurationStateError())); + + await tester.pumpWidget( + SettingsTestHelpers.buildSettingsWidget( + displayCubit: mockCubit, + child: const DisplaySettings(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Service error'), findsWidgets); + }); + + testWidgets('shows configuration when settings fetched', (tester) async { + final state = DisplayConfigurationStateSettingsFetched( + resolutionPreset: DisplayResolutionModePreset.res720p, + renderer: DisplayRendererType.mjpeg, + isResponsive: true, + quality: DisplayQualityPreset.quality80, + refreshRate: DisplayRefreshRatePreset.refresh30hz, + ); + + when(mockCubit.state).thenReturn(state); + when(mockCubit.stream).thenAnswer((_) => Stream.value(state)); + + await tester.pumpWidget( + SettingsTestHelpers.buildSettingsWidget( + displayCubit: mockCubit, + child: const DisplaySettings(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Renderer'), findsOneWidget); + expect(find.text('Resolution'), findsOneWidget); + expect(find.text('Image quality'), findsOneWidget); + expect(find.text('Refresh rate'), findsOneWidget); + }); + + testWidgets('shows renderer dropdown with current value', (tester) async { + final state = DisplayConfigurationStateSettingsFetched( + resolutionPreset: DisplayResolutionModePreset.res720p, + renderer: DisplayRendererType.mjpeg, + isResponsive: true, + quality: DisplayQualityPreset.quality80, + refreshRate: DisplayRefreshRatePreset.refresh30hz, + ); + + when(mockCubit.state).thenReturn(state); + when(mockCubit.stream).thenAnswer((_) => Stream.value(state)); + + await tester.pumpWidget( + SettingsTestHelpers.buildSettingsWidget( + displayCubit: mockCubit, + child: const DisplaySettings(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(DropdownButton), findsOneWidget); + }); + + testWidgets('shows resolution dropdown with current value', (tester) async { + final state = DisplayConfigurationStateSettingsFetched( + resolutionPreset: DisplayResolutionModePreset.res720p, + renderer: DisplayRendererType.mjpeg, + isResponsive: true, + quality: DisplayQualityPreset.quality80, + refreshRate: DisplayRefreshRatePreset.refresh30hz, + ); + + when(mockCubit.state).thenReturn(state); + when(mockCubit.stream).thenAnswer((_) => Stream.value(state)); + + await tester.pumpWidget( + SettingsTestHelpers.buildSettingsWidget( + displayCubit: mockCubit, + child: const DisplaySettings(), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byType(DropdownButton), + findsOneWidget, + ); + }); + + testWidgets('shows quality dropdown with current value', (tester) async { + final state = DisplayConfigurationStateSettingsFetched( + resolutionPreset: DisplayResolutionModePreset.res720p, + renderer: DisplayRendererType.mjpeg, + isResponsive: true, + quality: DisplayQualityPreset.quality80, + refreshRate: DisplayRefreshRatePreset.refresh30hz, + ); + + when(mockCubit.state).thenReturn(state); + when(mockCubit.stream).thenAnswer((_) => Stream.value(state)); + + await tester.pumpWidget( + SettingsTestHelpers.buildSettingsWidget( + displayCubit: mockCubit, + child: const DisplaySettings(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(DropdownButton), findsOneWidget); + }); + + testWidgets('shows refresh rate dropdown', (tester) async { + final state = DisplayConfigurationStateSettingsFetched( + resolutionPreset: DisplayResolutionModePreset.res720p, + renderer: DisplayRendererType.mjpeg, + isResponsive: true, + quality: DisplayQualityPreset.quality80, + refreshRate: DisplayRefreshRatePreset.refresh30hz, + ); + + when(mockCubit.state).thenReturn(state); + when(mockCubit.stream).thenAnswer((_) => Stream.value(state)); + + await tester.pumpWidget( + SettingsTestHelpers.buildSettingsWidget( + displayCubit: mockCubit, + child: const DisplaySettings(), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byType(DropdownButton), + findsOneWidget, + ); + }); + + testWidgets('shows responsive switch with current value', (tester) async { + final state = DisplayConfigurationStateSettingsFetched( + resolutionPreset: DisplayResolutionModePreset.res720p, + renderer: DisplayRendererType.mjpeg, + isResponsive: true, + quality: DisplayQualityPreset.quality80, + refreshRate: DisplayRefreshRatePreset.refresh30hz, + ); + + when(mockCubit.state).thenReturn(state); + when(mockCubit.stream).thenAnswer((_) => Stream.value(state)); + + await tester.pumpWidget( + SettingsTestHelpers.buildSettingsWidget( + displayCubit: mockCubit, + child: const DisplaySettings(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Dynamic aspect ratio'), findsOneWidget); + expect(find.byType(Switch), findsOneWidget); + }); + }); +} diff --git a/test/widget/settings/hotspot_settings_test.dart b/test/widget/settings/hotspot_settings_test.dart new file mode 100644 index 0000000..b84e903 --- /dev/null +++ b/test/widget/settings/hotspot_settings_test.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/feature/settings/bloc/system_configuration_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/system_configuration_state.dart'; +import 'package:tesla_android/feature/settings/widget/hotspot_settings.dart'; +import 'package:tesla_android/feature/settings/model/softap_band_type.dart'; + +import '../../helpers/mock_cubits.mocks.dart'; +import '../../helpers/test_fixtures.dart'; + +void main() { + late MockSystemConfigurationCubit mockCubit; + + setUp(() { + mockCubit = MockSystemConfigurationCubit(); + }); + + Widget makeTestableWidget(Widget child) { + return MaterialApp( + home: Scaffold( + body: BlocProvider.value( + value: mockCubit, + child: child, + ), + ), + ); + } + + group('HotspotSettings Widget', () { + testWidgets('shows loading indicator when state is not fetched/modified', ( + tester, + ) async { + when(mockCubit.state).thenReturn(SystemConfigurationStateInitial()); + when( + mockCubit.stream, + ).thenAnswer((_) => Stream.value(SystemConfigurationStateInitial())); + + await tester.pumpWidget(makeTestableWidget(const HotspotSettings())); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('shows configuration when state is Fetched', (tester) async { + final config = TestFixtures.systemConfiguration; + final state = SystemConfigurationStateSettingsFetched( + currentConfiguration: config, + ); + + when(mockCubit.state).thenReturn(state); + when(mockCubit.stream).thenAnswer((_) => Stream.value(state)); + + await tester.pumpWidget(makeTestableWidget(const HotspotSettings())); + await tester.pumpAndSettle(); + + // Verify Dropdown for Band Type + expect(find.text('Frequency band and channel'), findsOneWidget); + // Verify Switches (Offline mode, Telemetry, Updates) + expect(find.text('Offline mode'), findsOneWidget); + expect(find.text('Tesla Telemetry'), findsOneWidget); + expect(find.text('Tesla Software Updates'), findsOneWidget); + + // Verify loading indicator is gone + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + + testWidgets('calls updateSoftApBand when dropdown changed', (tester) async { + final config = + TestFixtures.systemConfiguration; // Default band is 1 (2.4GHz) + final state = SystemConfigurationStateSettingsFetched( + currentConfiguration: config, + ); + + when(mockCubit.state).thenReturn(state); + when(mockCubit.stream).thenAnswer((_) => Stream.value(state)); + + await tester.pumpWidget(makeTestableWidget(const HotspotSettings())); + await tester.pumpAndSettle(); + + // Find dropdown and tap it + final dropdownFinder = find.byType(DropdownButton); + await tester.tap(dropdownFinder); + await tester.pumpAndSettle(); + + // Select 5GHz (assuming it's in the list) + final itemFinder = find.text('5 GHZ - Channel 36').last; + + await tester.tap(itemFinder); + await tester.pumpAndSettle(); + + verify(mockCubit.updateSoftApBand(SoftApBandType.band5GHz36)).called(1); + }); + }); +} diff --git a/test/widget/settings/rear_display_settings_test.dart b/test/widget/settings/rear_display_settings_test.dart new file mode 100644 index 0000000..86c8c64 --- /dev/null +++ b/test/widget/settings/rear_display_settings_test.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/feature/settings/bloc/rear_display_configuration_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/rear_display_configuration_state.dart'; +import 'package:tesla_android/feature/settings/widget/rear_display_settings.dart'; + +import '../../helpers/mock_cubits.mocks.dart'; + +void main() { + late MockRearDisplayConfigurationCubit mockCubit; + + setUp(() { + mockCubit = MockRearDisplayConfigurationCubit(); + }); + + Widget makeTestableWidget(Widget child) { + return MaterialApp( + home: Scaffold( + body: BlocProvider.value( + value: mockCubit, + child: child, + ), + ), + ); + } + + group('RearDisplaySettings Widget', () { + testWidgets('shows loading indicator when loading', (tester) async { + when(mockCubit.state).thenReturn(RearDisplayConfigurationStateLoading()); + when( + mockCubit.stream, + ).thenAnswer((_) => Stream.value(RearDisplayConfigurationStateLoading())); + + await tester.pumpWidget(makeTestableWidget(const RearDisplaySettings())); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('shows error text when error state', (tester) async { + when(mockCubit.state).thenReturn(RearDisplayConfigurationStateError()); + when( + mockCubit.stream, + ).thenAnswer((_) => Stream.value(RearDisplayConfigurationStateError())); + + await tester.pumpWidget(makeTestableWidget(const RearDisplaySettings())); + await tester.pumpAndSettle(); + + expect(find.text('Service error'), findsWidgets); + }); + + testWidgets('shows rear display switch when settings fetched', ( + tester, + ) async { + final state = RearDisplayConfigurationStateSettingsFetched( + isRearDisplayEnabled: false, + isCurrentDisplayPrimary: false, + isRearDisplayPrioritised: false, + ); + + when(mockCubit.state).thenReturn(state); + when(mockCubit.stream).thenAnswer((_) => Stream.value(state)); + + await tester.pumpWidget(makeTestableWidget(const RearDisplaySettings())); + await tester.pumpAndSettle(); + + expect(find.text('Rear Display Support'), findsOneWidget); + expect(find.byType(Switch), findsOneWidget); + }); + + testWidgets('shows additional settings when rear display enabled', ( + tester, + ) async { + final state = RearDisplayConfigurationStateSettingsFetched( + isRearDisplayEnabled: true, + isCurrentDisplayPrimary: true, + isRearDisplayPrioritised: false, + ); + + when(mockCubit.state).thenReturn(state); + when(mockCubit.stream).thenAnswer((_) => Stream.value(state)); + + await tester.pumpWidget(makeTestableWidget(const RearDisplaySettings())); + await tester.pumpAndSettle(); + + expect(find.text('Primary Display'), findsOneWidget); + expect(find.text('Rear Display Priority'), findsOneWidget); + expect(find.byType(Switch), findsNWidgets(3)); // 3 switches total + }); + + testWidgets('calls setRearDisplayState when toggling rear display switch', ( + tester, + ) async { + final state = RearDisplayConfigurationStateSettingsFetched( + isRearDisplayEnabled: false, + isCurrentDisplayPrimary: false, + isRearDisplayPrioritised: false, + ); + + when(mockCubit.state).thenReturn(state); + when(mockCubit.stream).thenAnswer((_) => Stream.value(state)); + + await tester.pumpWidget(makeTestableWidget(const RearDisplaySettings())); + await tester.pumpAndSettle(); + + final switchFinder = find.byType(Switch); + await tester.tap(switchFinder); + await tester.pumpAndSettle(); + + verify(mockCubit.setRearDisplayState(true)).called(1); + }); + + testWidgets('calls setDisplayType when toggling primary display switch', ( + tester, + ) async { + final state = RearDisplayConfigurationStateSettingsFetched( + isRearDisplayEnabled: true, + isCurrentDisplayPrimary: false, + isRearDisplayPrioritised: false, + ); + + when(mockCubit.state).thenReturn(state); + when(mockCubit.stream).thenAnswer((_) => Stream.value(state)); + + await tester.pumpWidget(makeTestableWidget(const RearDisplaySettings())); + await tester.pumpAndSettle(); + + // Find the primary display switch (second switch in the list) + final switches = find.byType(Switch); + await tester.tap(switches.at(1)); + await tester.pumpAndSettle(); + + verify(mockCubit.setDisplayType(isCurrentDisplayPrimary: true)).called(1); + }); + + testWidgets( + 'calls setRearDisplayPrioritization when toggling priority switch', + (tester) async { + final state = RearDisplayConfigurationStateSettingsFetched( + isRearDisplayEnabled: true, + isCurrentDisplayPrimary: true, + isRearDisplayPrioritised: false, + ); + + when(mockCubit.state).thenReturn(state); + when(mockCubit.stream).thenAnswer((_) => Stream.value(state)); + + await tester.pumpWidget( + makeTestableWidget(const RearDisplaySettings()), + ); + await tester.pumpAndSettle(); + + // Find the priority switch (third switch in the list) + final switches = find.byType(Switch); + await tester.tap(switches.at(2)); + await tester.pumpAndSettle(); + + verify(mockCubit.setRearDisplayPrioritization(true)).called(1); + }, + ); + }); +} diff --git a/test/widget/settings/sound_settings_test.dart b/test/widget/settings/sound_settings_test.dart new file mode 100644 index 0000000..b79d801 --- /dev/null +++ b/test/widget/settings/sound_settings_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tesla_android/feature/settings/bloc/audio_configuration_cubit.dart'; +import 'package:tesla_android/feature/settings/bloc/audio_configuration_state.dart'; +import 'package:tesla_android/feature/settings/widget/sound_settings.dart'; + +import '../../helpers/mock_cubits.mocks.dart'; + +void main() { + late MockAudioConfigurationCubit mockCubit; + + setUp(() { + mockCubit = MockAudioConfigurationCubit(); + }); + + Widget makeTestableWidget(Widget child) { + return MaterialApp( + home: Scaffold( + body: BlocProvider.value( + value: mockCubit, + child: child, + ), + ), + ); + } + + group('SoundSettings Widget', () { + testWidgets('shows loading indicator when state is Loading', ( + tester, + ) async { + when(mockCubit.state).thenReturn(AudioConfigurationStateLoading()); + when( + mockCubit.stream, + ).thenAnswer((_) => Stream.value(AudioConfigurationStateLoading())); + + await tester.pumpWidget(makeTestableWidget(const SoundSettings())); + + expect(find.byType(CircularProgressIndicator), findsWidgets); + }); + + testWidgets('shows controls when state is Fetched', (tester) async { + final state = AudioConfigurationStateSettingsFetched( + isEnabled: true, + volume: 80, + ); + + when(mockCubit.state).thenReturn(state); + when(mockCubit.stream).thenAnswer((_) => Stream.value(state)); + + await tester.pumpWidget(makeTestableWidget(const SoundSettings())); + await tester.pumpAndSettle(); + + // Verify Switch + expect(find.byType(Switch), findsOneWidget); + expect(find.byType(Slider), findsOneWidget); + expect(find.text('80 %'), findsOneWidget); + }); + + testWidgets('calls setState when switch toggled', (tester) async { + final state = AudioConfigurationStateSettingsFetched( + isEnabled: true, + volume: 80, + ); + + when(mockCubit.state).thenReturn(state); + when(mockCubit.stream).thenAnswer((_) => Stream.value(state)); + + await tester.pumpWidget(makeTestableWidget(const SoundSettings())); + await tester.pumpAndSettle(); + + final switchFinder = find.byType(Switch); + await tester.tap(switchFinder); + await tester.pumpAndSettle(); + + verify(mockCubit.setState(false)).called(1); + }); + + testWidgets('calls setVolume when slider changed', (tester) async { + final state = AudioConfigurationStateSettingsFetched( + isEnabled: true, + volume: 50, + ); + + when(mockCubit.state).thenReturn(state); + when(mockCubit.stream).thenAnswer((_) => Stream.value(state)); + + await tester.pumpWidget(makeTestableWidget(const SoundSettings())); + await tester.pumpAndSettle(); + + final sliderFinder = find.byType(Slider); + await tester.drag(sliderFinder, const Offset(100, 0)); // Drag right + await tester.pumpAndSettle(); + + verify(mockCubit.setVolume(any)).called(greaterThan(0)); + }); + }); +} diff --git a/tools/mock_backend/mock_backend.py b/tools/mock_backend/mock_backend.py new file mode 100644 index 0000000..7007f42 --- /dev/null +++ b/tools/mock_backend/mock_backend.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +""" +Tesla Android Mock Backend with Raw WebSocket Support + +Uses aiohttp for REST API and raw WebSocket connections (not Socket.IO). +This matches the protocol expected by reconnecting-websocket.js. + +Requirements: + pip install aiohttp aiohttp-cors websockets + +Usage: + python3 mock_backend_raw_ws.py +""" + +import asyncio +import json +from datetime import datetime +from aiohttp import web +import aiohttp_cors +import random + +# Mock state +mock_state = { + "display": { + "width": 1920, "height": 1080, "density": 200, + "resolutionPreset": 0, "renderer": 1, "isHeadless": 0, + "isResponsive": 1, "isH264": 1, "refreshRate": 30, + "quality": 90, "isRearDisplayEnabled": 0, "isRearDisplayPrioritised": 0, + }, + "configuration": { + "persist.tesla-android.softap.band_type": 1, + "persist.tesla-android.softap.channel": 36, + "persist.tesla-android.softap.channel_width": 80, + "persist.tesla-android.softap.is_enabled": 1, + "persist.tesla-android.offline-mode.is_enabled": 0, + "persist.tesla-android.offline-mode.telemetry.is_enabled": 1, + "persist.tesla-android.offline-mode.tesla-firmware-downloads": 1, + "persist.tesla-android.browser_audio.is_enabled": 1, + "persist.tesla-android.browser_audio.volume": 80, + "persist.tesla-android.gps.is_active": 1, + }, + "device_info": { + "cpu_temperature": 45, "serial_number": "MOCK0000001", + "device_model": "cm4", "is_modem_detected": 1, + "is_carplay_detected": 0, "release_type": "stable", + "ota_url": "https://github.com/tesla-android/android-raspberry-pi/releases", + "is_gps_enabled": 1, + } +} + +# WebSocket connections +websocket_connections = { + "display": set(), + "touchscreen": set(), + "gps": set(), + "audio": set() +} + +# ============================================================================ +# REST API Handlers +# ============================================================================ + +async def health_check(request): + return web.json_response({"status": "ok", "timestamp": datetime.now().isoformat()}) + +async def get_configuration(request): + print("[GET] /api/configuration") + return web.json_response(mock_state["configuration"]) + +async def get_display_state(request): + print("[GET] /api/displayState") + return web.json_response(mock_state["display"]) + +async def update_display_configuration(request): + config = await request.json() + print(f"[POST] /api/displayState") + for key, value in config.items(): + if key in mock_state["display"]: + mock_state["display"][key] = value + return web.Response(status=200) + +async def get_device_info(request): + print("[GET] /api/deviceInfo") + mock_state["device_info"]["cpu_temperature"] = random.randint(40, 55) + return web.json_response(mock_state["device_info"]) + +async def open_updater(request): + print("[GET] /api/openUpdater") + return web.json_response({"message": "Updater opened (mock)"}) + +# Configuration POST handlers +async def post_handler(request, key): + data = await request.text() + value = int(data) + print(f"[POST] {request.path}: {value}") + mock_state["configuration"][key] = value + if "gps" in key.lower(): + mock_state["device_info"]["is_gps_enabled"] = value + return web.Response(status=200) + +# ============================================================================ +# WebSocket Handlers (Raw WebSocket Protocol) +# ============================================================================ + +async def websocket_handler(request, socket_type): + """Handle raw WebSocket connections""" + ws = web.WebSocketResponse() + await ws.prepare(request) + + print(f"[{socket_type.title()} WebSocket] Client connected") + websocket_connections[socket_type].add(ws) + + try: + # Send welcome message + await ws.send_str(json.dumps({ + "type": "connected", + "socket": socket_type, + "timestamp": datetime.now().isoformat() + })) + + # Handle incoming messages + async for msg in ws: + if msg.type == web.WSMsgType.TEXT: + # Just log received data for touchscreen/gps + if socket_type in ["touchscreen", "gps"]: + # Don't spam logs for every touch event + if socket_type == "gps": + print(f"[{socket_type.title()}] Received: {msg.data[:100]}") + pass # Silently handle for touchscreen + + elif msg.type == web.WSMsgType.ERROR: + print(f"[{socket_type.title()} WebSocket] Error: {ws.exception()}") + + finally: + websocket_connections[socket_type].discard(ws) + print(f"[{socket_type.title()} WebSocket] Client disconnected") + + return ws + +async def display_websocket(request): + return await websocket_handler(request, "display") + +async def touchscreen_websocket(request): + return await websocket_handler(request, "touchscreen") + +async def gps_websocket(request): + return await websocket_handler(request, "gps") + +async def audio_websocket(request): + return await websocket_handler(request, "audio") + +# ============================================================================ +# Debug Handlers +# ============================================================================ + +async def debug_state(request): + return web.json_response(mock_state) + +async def debug_websockets(request): + return web.json_response({ + "connections": { + socket_type: len(sids) + for socket_type, sids in websocket_connections.items() + } + }) + +async def debug_reset(request): + global mock_state + mock_state = { + "display": { + "width": 1920, "height": 1080, "density": 200, + "resolutionPreset": 0, "renderer": 1, "isHeadless": 0, + "isResponsive": 1, "isH264": 1, "refreshRate": 30, + "quality": 90, "isRearDisplayEnabled": 0, "isRearDisplayPrioritised": 0, + }, + "configuration": { + "persist.tesla-android.softap.band_type": 1, + "persist.tesla-android.softap.channel": 36, + "persist.tesla-android.softap.channel_width": 80, + "persist.tesla-android.softap.is_enabled": 1, + "persist.tesla-android.offline-mode.is_enabled": 0, + "persist.tesla-android.offline-mode.telemetry.is_enabled": 1, + "persist.tesla-android.offline-mode.tesla-firmware-downloads": 1, + "persist.tesla-android.browser_audio.is_enabled": 1, + "persist.tesla-android.browser_audio.volume": 80, + "persist.tesla-android.gps.is_active": 1, + }, + "device_info": { + "cpu_temperature": 45, "serial_number": "MOCK0000001", + "device_model": "cm4", "is_modem_detected": 1, + "is_carplay_detected": 0, "release_type": "stable", + "ota_url": "https://github.com/tesla-android/android-raspberry-pi/releases", + "is_gps_enabled": 1, + } + } + return web.json_response({"message": "State reset to defaults"}) + +# ============================================================================ +# Application Setup +# ============================================================================ + +async def create_app(): + app = web.Application() + + # Configure CORS + cors = aiohttp_cors.setup(app, defaults={ + "*": aiohttp_cors.ResourceOptions( + allow_credentials=True, + expose_headers="*", + allow_headers="*", + ) + }) + + # REST API routes + app.router.add_get('/api/health', health_check) + app.router.add_get('/api/configuration', get_configuration) + app.router.add_get('/api/displayState', get_display_state) + app.router.add_post('/api/displayState', update_display_configuration) + app.router.add_get('/api/deviceInfo', get_device_info) + app.router.add_get('/api/openUpdater', open_updater) + + # Configuration POST routes + app.router.add_post('/api/softApBand', + lambda r: post_handler(r, "persist.tesla-android.softap.band_type")) + app.router.add_post('/api/softApChannel', + lambda r: post_handler(r, "persist.tesla-android.softap.channel")) + app.router.add_post('/api/softApChannelWidth', + lambda r: post_handler(r, "persist.tesla-android.softap.channel_width")) + app.router.add_post('/api/softApState', + lambda r: post_handler(r, "persist.tesla-android.softap.is_enabled")) + app.router.add_post('/api/offlineModeState', + lambda r: post_handler(r, "persist.tesla-android.offline-mode.is_enabled")) + app.router.add_post('/api/offlineModeTelemetryState', + lambda r: post_handler(r, "persist.tesla-android.offline-mode.telemetry.is_enabled")) + app.router.add_post('/api/offlineModeTeslaFirmwareDownloads', + lambda r: post_handler(r, "persist.tesla-android.offline-mode.tesla-firmware-downloads")) + app.router.add_post('/api/browserAudioState', + lambda r: post_handler(r, "persist.tesla-android.browser_audio.is_enabled")) + app.router.add_post('/api/browserAudioVolume', + lambda r: post_handler(r, "persist.tesla-android.browser_audio.volume")) + app.router.add_post('/api/gpsState', + lambda r: post_handler(r, "persist.tesla-android.gps.is_active")) + + # WebSocket routes + app.router.add_get('/sockets/display', display_websocket) + app.router.add_get('/sockets/touchscreen', touchscreen_websocket) + app.router.add_get('/sockets/gps', gps_websocket) + app.router.add_get('/sockets/audio', audio_websocket) + + # Debug routes + app.router.add_get('/api/debug/state', debug_state) + app.router.add_get('/api/debug/websockets', debug_websockets) + app.router.add_post('/api/debug/reset', debug_reset) + + # Configure CORS for all routes + for route in list(app.router.routes()): + if not isinstance(route.resource, web.StaticResource): + cors.add(route) + + return app + +# ============================================================================ +# Main +# ============================================================================ + +if __name__ == '__main__': + print("=" * 70) + print("Tesla Android Mock Backend - Raw WebSocket Support") + print("=" * 70) + print("\nServer starting on http://localhost:3000") + print("\nRaw WebSocket Endpoints:") + print(" ws://localhost:3000/sockets/display") + print(" ws://localhost:3000/sockets/touchscreen") + print(" ws://localhost:3000/sockets/gps") + print(" ws://localhost:3000/sockets/audio") + print("\nREST API Endpoints:") + print(" GET /api/health") + print(" GET /api/configuration") + print(" GET /api/displayState") + print(" POST /api/displayState") + print(" GET /api/deviceInfo") + print(" GET /api/debug/state") + print(" GET /api/debug/websockets") + print(" POST /api/debug/reset") + print("=" * 70) + print() + + web.run_app(create_app(), host='0.0.0.0', port=3000) diff --git a/web/android.html b/web/android.html index d2fe37d..13515c0 100644 --- a/web/android.html +++ b/web/android.html @@ -96,10 +96,17 @@ if (!displayRendererAdded) { var rendererScript = document.createElement('script'); rendererScript.src = renderer + '.js'; + rendererScript.onload = function() { + initDisplaySocket(url, binaryType); + }; document.head.appendChild(rendererScript); displayRendererAdded = true; + } else { + initDisplaySocket(url, binaryType); } + } + function initDisplaySocket(url, binaryType) { displaySocket = new ReconnectingWebSocket(url, null, { binaryType: binaryType }); displaySocket.onopen = () => log("Display: Websocket connection established"); @@ -107,7 +114,11 @@ displaySocket.onerror = error => log("Display: " + error); displaySocket.onmessage = (event) => { - drawDisplayFrame(event.data); + if (typeof drawDisplayFrame === "function") { + drawDisplayFrame(event.data); + } else { + log("Error: drawDisplayFrame not defined yet"); + } }; } diff --git a/web/icons/manifest.json b/web/icons/manifest.json index 013d4a6..ec7c350 100644 --- a/web/icons/manifest.json +++ b/web/icons/manifest.json @@ -2,37 +2,37 @@ "name": "App", "icons": [ { - "src": "\/android-icon-36x36.png", + "src": "android-icon-36x36.png", "sizes": "36x36", "type": "image\/png", "density": "0.75" }, { - "src": "\/android-icon-48x48.png", + "src": "android-icon-48x48.png", "sizes": "48x48", "type": "image\/png", "density": "1.0" }, { - "src": "\/android-icon-72x72.png", + "src": "android-icon-72x72.png", "sizes": "72x72", "type": "image\/png", "density": "1.5" }, { - "src": "\/android-icon-96x96.png", + "src": "android-icon-96x96.png", "sizes": "96x96", "type": "image\/png", "density": "2.0" }, { - "src": "\/android-icon-144x144.png", + "src": "android-icon-144x144.png", "sizes": "144x144", "type": "image\/png", "density": "3.0" }, { - "src": "\/android-icon-192x192.png", + "src": "android-icon-192x192.png", "sizes": "192x192", "type": "image\/png", "density": "4.0"