diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index b5c1d9db..99e44d0e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -15,6 +15,18 @@ GitHub Issue: # +## GitHub Copilot Template Used (if applicable) + + +- [ ] [Feature Development Template](.github/prompt-templates/feature-development.md) +- [ ] [Bug Fix Template](.github/prompt-templates/bug-fix.md) +- [ ] [Refactoring Template](.github/prompt-templates/refactoring.md) +- [ ] [Testing Template](.github/prompt-templates/testing.md) +- [ ] [UI Component Template](.github/prompt-templates/ui-component.md) +- [ ] [API Integration Template](.github/prompt-templates/api-integration.md) +- [ ] [Performance Optimization Template](.github/prompt-templates/performance-optimization.md) +- [ ] None - developed without template assistance + ## Impact on version @@ -50,6 +62,16 @@ Based on your changes these checks may not apply. - [ ] Automated tests for the changes have been added/updated. - [ ] Tested on all relevant platforms +### Architecture & Code Quality +For significant changes, ensure architecture patterns are followed. +- [ ] **MVVM Pattern**: ViewModels extend base ViewModel class and handle UI state +- [ ] **Clean Architecture**: Proper separation between Access/Business/Presentation layers +- [ ] **Dependency Injection**: Services registered in GetIt and injected properly +- [ ] **Repository Pattern**: Data access uses repository interfaces with Retrofit +- [ ] **Error Handling**: Appropriate exception handling and user-friendly error messages +- [ ] **Performance**: No performance regressions, efficient widget usage +- [ ] **Testing**: Unit tests for business logic, widget tests for UI components + ## Other information diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..81518ff4 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,310 @@ +# GitHub Copilot Instructions for Flutter Application Template + +## Project Context + +This is a **Flutter/Dart project** that serves as a template for building production-ready mobile applications. The project follows a **Clean Architecture + MVVM pattern** with strict separation of concerns across three main layers. + +### Architecture Overview + +**Layer Structure:** +- **Access Layer** (`/lib/access/`) - Data sources, repositories, API clients, persistence +- **Business Layer** (`/lib/business/`) - Services, domain models, business logic +- **Presentation Layer** (`/lib/presentation/`) - UI pages, ViewModels, widgets + +**Key Technologies:** +- **State Management**: Custom MVVM framework with RxDart streams +- **Dependency Injection**: GetIt service locator +- **HTTP Client**: Dio + Retrofit for API integration +- **Navigation**: GoRouter for declarative routing +- **Localization**: Flutter intl with code generation +- **Testing**: flutter_test, mockito for mocking +- **Logging**: Logger package with custom filters +- **Analytics**: Firebase Analytics and Remote Config + +## Code Style Guidelines + +### Dart/Flutter Standards +- Follow the [official Dart style guide](https://dart.dev/guides/language/effective-dart/style) +- Use meaningful, descriptive names for variables, functions, and classes +- Prefer composition over inheritance +- Always use `const` constructors where possible +- Implement proper null safety with `?`, `!`, and null-aware operators +- Use `final` for immutable variables and `late` for late initialization + +### Project-Specific Conventions +- Use `final class` for implementation classes that shouldn't be extended +- Implement interfaces with `abstract interface class` +- Prefer factory constructors for service implementations +- Use trailing commas for better git diffs (enforced by linter) +- Always declare return types explicitly +- Use package imports (no relative imports) + +### Naming Conventions +```dart +// Classes: PascalCase +class DadJokesService {} +abstract interface class DadJokesRepository {} + +// Files: snake_case matching class names +dad_jokes_service.dart +dad_jokes_repository.dart + +// Variables and functions: camelCase +final dadJokesService = GetIt.I(); +Future toggleIsFavorite(DadJoke dadJoke) async {} + +// Private members: prefix with underscore +final DadJokesRepository _dadJokesRepository; +``` + +## MVVM Framework Usage + +### ViewModel Implementation +```dart +// Extend from ViewModel base class +class DadJokesPageViewModel extends ViewModel { + final _service = GetIt.I(); + + // Use getLazy for stream properties + Stream> get dadJokesStream => + getLazy("dadJokesStream", () => _service.dadJokesStream); + + // Use get/set for simple properties + bool get isLoading => get("isLoading", false); + void _setLoading(bool value) => set("isLoading", value); +} +``` + +### Widget Implementation +```dart +// Extend MvvmWidget with ViewModel type +final class DadJokesPage extends MvvmWidget { + const DadJokesPage({super.key}); + + @override + DadJokesPageViewModel getViewModel() => DadJokesPageViewModel(); + + @override + Widget build(BuildContext context, DadJokesPageViewModel viewModel) { + return Scaffold(/* UI implementation */); + } +} +``` + +## Repository Pattern Implementation + +### Interface Definition +```dart +@RestApi(baseUrl: 'https://api.example.com') +abstract interface class ExampleRepository { + factory ExampleRepository(Dio dio, {String baseUrl}) = _ExampleRepository; + + @GET('/endpoint') + Future getData(); +} +``` + +### Service Layer Usage +```dart +abstract interface class ExampleService implements Disposable { + factory ExampleService(ExampleRepository repository, Logger logger) = _ExampleService; + + Stream> get dataStream; + Future> getData(); +} +``` + +## Flutter-Specific Best Practices + +### Widget Lifecycle +- Always use `StatelessWidget` when no local state is needed +- Prefer `const` constructors for better performance +- Use `Key` parameters for widgets that need to maintain state +- Implement proper disposal in ViewModels (streams, subscriptions) + +### UI Patterns +```dart +// Use StreamBuilder for reactive UI +StreamBuilder>( + stream: viewModel.dataStream, + builder: (context, snapshot) { + if (snapshot.hasData) { + return DataListView(data: snapshot.data!); + } else if (snapshot.hasError) { + return ErrorWidget(error: snapshot.error!); + } else { + return const CircularProgressIndicator(); + } + }, +) + +// Use proper localization +final local = context.local; +Text(local.welcomeMessage) + +// Implement responsive design +LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth > 600) { + return TabletLayout(); + } else { + return MobileLayout(); + } + }, +) +``` + +### Async Patterns +```dart +// Always use async/await for better error handling +Future loadData() async { + try { + _setLoading(true); + final data = await _service.getData(); + _updateData(data); + } catch (error) { + _logger.e('Failed to load data', error); + _setError(error.toString()); + } finally { + _setLoading(false); + } +} +``` + +## Testing Guidelines + +### Test Structure +```dart +// Unit tests for services +group('DadJokesService', () { + late DadJokesService service; + late MockDadJokesRepository mockRepository; + + setUp(() { + mockRepository = MockDadJokesRepository(); + service = DadJokesService(mockRepository, MockLogger()); + }); + + test('should return dad jokes when repository succeeds', () async { + // Arrange + when(mockRepository.getDadJokes()).thenAnswer((_) async => mockData); + + // Act + final result = await service.getDadJokes(); + + // Assert + expect(result, isA>()); + verify(mockRepository.getDadJokes()).called(1); + }); +}); +``` + +### Widget Testing +```dart +testWidgets('DadJokesPage displays jokes correctly', (tester) async { + // Arrange + await tester.pumpWidget(TestApp(child: DadJokesPage())); + + // Act + await tester.pumpAndSettle(); + + // Assert + expect(find.byType(ListView), findsOneWidget); + expect(find.byKey(Key('DadJokesContainer')), findsOneWidget); +}); +``` + +## Dependency Injection Setup + +### Service Registration +```dart +// Register services in GetIt +GetIt.I.registerSingleton( + DadJokesService( + GetIt.I(), + GetIt.I(), + ), +); + +// Register repositories +GetIt.I.registerSingleton( + DadJokesRepository(GetIt.I()), +); +``` + +## Error Handling and Logging + +### Logging Patterns +```dart +class ExampleService { + final Logger _logger; + + Future performAction() async { + _logger.d('Starting action with parameters: $params'); + + try { + final result = await _repository.performAction(); + _logger.i('Action completed successfully'); + return result; + } catch (error, stackTrace) { + _logger.e('Action failed', error, stackTrace); + rethrow; + } + } +} +``` + +### Exception Handling +```dart +// Use custom exceptions for business logic +class PersistenceException implements Exception { + final String message; + const PersistenceException(this.message); +} + +// Handle exceptions in ViewModels +try { + await _service.performAction(); +} on PersistenceException catch (e) { + _setError('Data persistence failed: ${e.message}'); +} catch (e) { + _setError('An unexpected error occurred'); +} +``` + +## Performance Considerations + +- Use `const` constructors wherever possible +- Implement lazy loading for expensive operations +- Dispose of streams and subscriptions properly +- Use `ListView.builder` for large lists +- Cache network responses when appropriate +- Minimize widget rebuilds with proper state management + +## Common Gotchas + +1. **Stream Subscriptions**: Always dispose of stream subscriptions to prevent memory leaks +2. **GetIt Registration**: Ensure services are registered before use +3. **Null Safety**: Use null-aware operators properly (`?.`, `??`, `!`) +4. **Async Gaps**: Avoid gaps in async operations that could cause race conditions +5. **Widget Keys**: Use keys for widgets that need to maintain state across rebuilds +6. **Context Usage**: Don't use BuildContext across async gaps + +## File Organization + +``` +lib/ +├── access/ # Data layer +│ ├── [feature]/ # Feature-specific repositories +│ └── data/ # Data models +├── business/ # Business logic layer +│ └── [feature]/ # Feature-specific services +├── presentation/ # UI layer +│ ├── [feature]/ # Feature-specific pages/widgets +│ ├── mvvm/ # MVVM framework +│ └── styles/ # Theming and styles +├── l10n/ # Localization +└── main.dart # App entry point +``` + +Follow this structure when creating new features or components. \ No newline at end of file diff --git a/.github/copilot-workspace.yml b/.github/copilot-workspace.yml new file mode 100644 index 00000000..e52ca1c6 --- /dev/null +++ b/.github/copilot-workspace.yml @@ -0,0 +1,138 @@ +# Copilot Workspace Configuration + +# Flutter/Dart specific settings for GitHub Copilot +version: 1 + +# Project context for better suggestions +project: + type: "flutter" + language: "dart" + architecture: "mvvm-clean-architecture" + + # Key technologies used in this project + technologies: + - "flutter" + - "dart" + - "rxdart" + - "get_it" + - "retrofit" + - "dio" + - "go_router" + - "firebase" + - "mockito" + +# Code style preferences +preferences: + # Dart/Flutter specific preferences + dart: + use_trailing_commas: true + prefer_const_constructors: true + always_declare_return_types: true + use_package_imports: true + + # Architecture patterns + architecture: + layer_separation: true + dependency_injection: "get_it" + state_management: "mvvm_custom" + + # Testing preferences + testing: + framework: "flutter_test" + mocking: "mockito" + coverage_threshold: 80 + +# File patterns and templates +patterns: + # Repository pattern + repository: + interface: "abstract interface class {Name}Repository" + implementation: "final class _{Name}Repository implements {Name}Repository" + location: "lib/access/{feature}/" + + # Service pattern + service: + interface: "abstract interface class {Name}Service implements Disposable" + implementation: "final class _{Name}Service implements {Name}Service" + location: "lib/business/{feature}/" + + # ViewModel pattern + viewmodel: + class: "class {Name}ViewModel extends ViewModel" + location: "lib/presentation/{feature}/" + + # Widget pattern + widget: + stateless: "final class {Name} extends StatelessWidget" + mvvm: "final class {Name} extends MvvmWidget<{Name}ViewModel>" + location: "lib/presentation/{feature}/" + +# Common imports for different file types +imports: + repository: + - "package:dio/dio.dart" + - "package:retrofit/retrofit.dart" + + service: + - "package:get_it/get_it.dart" + - "package:logger/logger.dart" + - "package:rxdart/rxdart.dart" + + viewmodel: + - "package:app/presentation/mvvm/view_model.dart" + - "package:get_it/get_it.dart" + + widget: + - "package:flutter/material.dart" + - "package:app/presentation/mvvm/mvvm_widget.dart" + - "package:app/l10n/localization_extensions.dart" + +# Suggestions for common tasks +suggestions: + # When creating a new feature + new_feature: + - "Create data models in lib/access/{feature}/data/" + - "Create repository interface with Retrofit annotations" + - "Create service with business logic" + - "Create ViewModel extending from ViewModel base class" + - "Create Page widget extending MvvmWidget" + - "Register dependencies in GetIt" + - "Add routes to GoRouter configuration" + - "Add localization strings" + - "Write unit tests for service and ViewModel" + + # When fixing bugs + bug_fix: + - "Check logs for error messages" + - "Add debug logging to trace issue" + - "Create test case that reproduces the bug" + - "Fix with minimal changes" + - "Verify fix doesn't break existing functionality" + + # When optimizing performance + performance: + - "Use ListView.builder for large lists" + - "Add const constructors where possible" + - "Check for memory leaks in streams and subscriptions" + - "Profile with Flutter Inspector" + - "Optimize image loading and caching" + +# Quality gates +quality: + # Code quality requirements + code: + max_cyclomatic_complexity: 10 + max_lines_per_method: 50 + max_parameters: 5 + + # Testing requirements + testing: + min_coverage_percentage: 80 + require_unit_tests: true + require_widget_tests: true + + # Performance requirements + performance: + max_build_time_ms: 100 + max_frame_time_ms: 16 + max_memory_usage_mb: 150 \ No newline at end of file diff --git a/.github/prompt-templates/README.md b/.github/prompt-templates/README.md new file mode 100644 index 00000000..9dbca957 --- /dev/null +++ b/.github/prompt-templates/README.md @@ -0,0 +1,158 @@ +# GitHub Copilot Prompt Templates + +This directory contains comprehensive prompt templates designed to help developers work effectively with GitHub Copilot in this Flutter application template. These templates follow the project's established MVVM + Clean Architecture patterns and coding standards. + +## Quick Setup + +1. **Configure GitHub Copilot**: Make sure you have GitHub Copilot enabled in your IDE +2. **Read the Instructions**: Review [copilot-instructions.md](../copilot-instructions.md) for project-specific patterns +3. **Choose a Template**: Select the appropriate template for your task from the list below +4. **Follow the Template**: Copy and paste the relevant sections into your Copilot chat or comments + +## Available Templates + +### 🏗️ [Feature Development](feature-development.md) +**Use when**: Creating new features following the MVVM + Clean Architecture pattern +- Complete feature implementation guide +- Data models, repositories, services, and ViewModels +- UI components and navigation setup +- Testing and dependency injection + +### 🐛 [Bug Fix](bug-fix.md) +**Use when**: Debugging and fixing issues systematically +- Systematic debugging approach +- Layer-specific debugging strategies +- Error reproduction and testing +- Performance and memory issue diagnosis + +### 🔧 [Refactoring](refactoring.md) +**Use when**: Improving code structure while preserving functionality +- Safe refactoring practices +- Architecture pattern improvements +- Performance optimization during refactoring +- Testing during code changes + +### 🧪 [Testing](testing.md) +**Use when**: Writing comprehensive tests for your code +- Unit, widget, and integration testing +- Mock implementations and test data factories +- Performance and accessibility testing +- CI/CD test integration + +### 🎨 [UI Component](ui-component.md) +**Use when**: Creating reusable UI components +- Design system compliance +- Responsive and accessible components +- Performance-optimized widgets +- Component testing strategies + +### 🌐 [API Integration](api-integration.md) +**Use when**: Integrating with REST APIs or external services +- Repository pattern with Retrofit +- Error handling and authentication +- Caching and performance optimization +- Network testing and mocking + +### ⚡ [Performance Optimization](performance-optimization.md) +**Use when**: Improving app performance and responsiveness +- UI rendering optimization +- Memory management +- Network and database performance +- Performance monitoring and testing + +## How to Use These Templates + +### Method 1: Direct Copy-Paste +1. Open the relevant template file +2. Copy the sections you need +3. Paste into GitHub Copilot chat or code comments +4. Follow the step-by-step instructions + +### Method 2: Reference in Comments +```dart +// Using GitHub Copilot Feature Development Template +// Create a user management service following the repository pattern +// with proper error handling and stream-based state management +``` + +### Method 3: Copilot Chat Integration +In GitHub Copilot chat, reference templates like this: +``` +@workspace Using the API Integration template, help me create a repository for managing user data with proper error handling and caching. +``` + +## Template Structure + +Each template follows this consistent structure: + +1. **Context Section**: Describes when and how to use the template +2. **Implementation Steps**: Step-by-step instructions with code examples +3. **Architecture Patterns**: How to follow the project's established patterns +4. **Testing Guidelines**: How to test the implemented code +5. **Performance Considerations**: Optimization tips and best practices +6. **Common Pitfalls**: Anti-patterns to avoid +7. **Checklists**: Validation steps to ensure quality + +## Best Practices + +### ✅ Do +- Read the [copilot-instructions.md](../copilot-instructions.md) first +- Choose the most appropriate template for your task +- Follow the step-by-step instructions in order +- Adapt the examples to your specific use case +- Use the checklists to validate your implementation +- Test your code thoroughly + +### ❌ Don't +- Skip the architecture guidelines +- Modify the established patterns without good reason +- Ignore the testing requirements +- Copy code without understanding it +- Forget to handle error cases +- Skip performance considerations + +## Contributing to Templates + +If you find improvements or need additional templates: + +1. **For improvements**: Create a PR with your suggested changes +2. **For new templates**: Follow the existing template structure +3. **For questions**: Open an issue with the `copilot-templates` label + +## Project Architecture Reminder + +This Flutter template uses: +- **MVVM Pattern**: Custom ViewModel base class with property change notification +- **Clean Architecture**: Access (data) → Business (logic) → Presentation (UI) layers +- **Dependency Injection**: GetIt service locator pattern +- **Repository Pattern**: Retrofit-based API integration +- **State Management**: RxDart streams with reactive UI +- **Testing**: Unit tests, widget tests, and integration tests + +## IDE Integration + +### VS Code +1. Install the GitHub Copilot extension +2. Use `Ctrl+I` (or `Cmd+I`) to open Copilot chat +3. Reference templates with `@workspace` mentions + +### Android Studio/IntelliJ +1. Install the GitHub Copilot plugin +2. Use the Copilot tool window +3. Copy template content into chat + +## Template Updates + +These templates are living documents that evolve with the project: +- Templates are updated when architecture patterns change +- New templates are added for emerging development patterns +- Examples are kept current with the latest project structure + +For the most up-to-date templates, always refer to the main branch of this repository. + +--- + +**Need Help?** +- Check the [project documentation](../../doc/) for architecture details +- Review existing code examples in the `src/app/lib/` directory +- Open an issue if templates need clarification or improvements \ No newline at end of file diff --git a/.github/prompt-templates/api-integration.md b/.github/prompt-templates/api-integration.md new file mode 100644 index 00000000..ae3473e0 --- /dev/null +++ b/.github/prompt-templates/api-integration.md @@ -0,0 +1,1409 @@ +# API Integration Template + +Use this template when integrating with APIs in the Flutter application. This template follows the project's Repository pattern with Retrofit/Dio and includes proper error handling, caching, and testing strategies. + +## API Integration Context +**API Name**: [Specify the API or service name] +**Base URL**: [API base URL] +**Authentication**: [API key, OAuth, JWT, etc.] +**Data Format**: [JSON, XML, etc.] +**Rate Limits**: [Any rate limiting considerations] + +## Integration Architecture + +The project follows this pattern for API integration: +``` +Presentation Layer (ViewModel) +↓ +Business Layer (Service) +↓ +Access Layer (Repository - Retrofit) +↓ +Network Layer (Dio HTTP Client) +``` + +## Implementation Steps + +### 1. Data Models + +#### Response Data Models +```dart +// lib/access/[feature]/data/[model]_response_data.dart +import 'package:json_annotation/json_annotation.dart'; + +part '[model]_response_data.g.dart'; + +@JsonSerializable() +class UserResponseData { + @JsonKey(name: 'id') + final String id; + + @JsonKey(name: 'first_name') + final String firstName; + + @JsonKey(name: 'last_name') + final String lastName; + + @JsonKey(name: 'email') + final String email; + + @JsonKey(name: 'avatar_url') + final String? avatarUrl; + + @JsonKey(name: 'created_at') + final DateTime createdAt; + + @JsonKey(name: 'is_active') + final bool isActive; + + const UserResponseData({ + required this.id, + required this.firstName, + required this.lastName, + required this.email, + this.avatarUrl, + required this.createdAt, + required this.isActive, + }); + + factory UserResponseData.fromJson(Map json) => + _$UserResponseDataFromJson(json); + + Map toJson() => _$UserResponseDataToJson(this); +} + +// For paginated responses +@JsonSerializable() +class PaginatedResponse { + @JsonKey(name: 'data') + final List data; + + @JsonKey(name: 'meta') + final PaginationMeta meta; + + const PaginatedResponse({ + required this.data, + required this.meta, + }); + + factory PaginatedResponse.fromJson( + Map json, + T Function(Object? json) fromJsonT, + ) => + _$PaginatedResponseFromJson(json, fromJsonT); + + Map toJson(Object? Function(T value) toJsonT) => + _$PaginatedResponseToJson(this, toJsonT); +} + +@JsonSerializable() +class PaginationMeta { + @JsonKey(name: 'page') + final int page; + + @JsonKey(name: 'per_page') + final int perPage; + + @JsonKey(name: 'total') + final int total; + + @JsonKey(name: 'total_pages') + final int totalPages; + + const PaginationMeta({ + required this.page, + required this.perPage, + required this.total, + required this.totalPages, + }); + + factory PaginationMeta.fromJson(Map json) => + _$PaginationMetaFromJson(json); + + Map toJson() => _$PaginationMetaToJson(this); +} +``` + +#### Request Data Models +```dart +// lib/access/[feature]/data/[model]_request_data.dart +import 'package:json_annotation/json_annotation.dart'; + +part '[model]_request_data.g.dart'; + +@JsonSerializable() +class CreateUserRequestData { + @JsonKey(name: 'first_name') + final String firstName; + + @JsonKey(name: 'last_name') + final String lastName; + + @JsonKey(name: 'email') + final String email; + + @JsonKey(name: 'password') + final String password; + + const CreateUserRequestData({ + required this.firstName, + required this.lastName, + required this.email, + required this.password, + }); + + factory CreateUserRequestData.fromJson(Map json) => + _$CreateUserRequestDataFromJson(json); + + Map toJson() => _$CreateUserRequestDataToJson(this); +} + +@JsonSerializable() +class UpdateUserRequestData { + @JsonKey(name: 'first_name') + final String? firstName; + + @JsonKey(name: 'last_name') + final String? lastName; + + @JsonKey(name: 'email') + final String? email; + + const UpdateUserRequestData({ + this.firstName, + this.lastName, + this.email, + }); + + factory UpdateUserRequestData.fromJson(Map json) => + _$UpdateUserRequestDataFromJson(json); + + Map toJson() => _$UpdateUserRequestDataToJson(this); +} +``` + +### 2. Repository Interface & Implementation + +#### Repository Interface +```dart +// lib/access/[feature]/[feature]_repository.dart +import 'package:dio/dio.dart'; +import 'package:retrofit/retrofit.dart'; +import 'package:app/access/[feature]/data/user_response_data.dart'; +import 'package:app/access/[feature]/data/create_user_request_data.dart'; +import 'package:app/access/[feature]/data/update_user_request_data.dart'; + +part '[feature]_repository.g.dart'; + +@RestApi(baseUrl: 'https://api.example.com/v1') +abstract interface class UserRepository { + factory UserRepository(Dio dio, {String baseUrl}) = _UserRepository; + + // GET requests + @GET('/users') + Future> getUsers({ + @Query('page') int page = 1, + @Query('per_page') int perPage = 20, + @Query('search') String? search, + @Query('sort_by') String? sortBy, + @Query('sort_order') String? sortOrder, + }); + + @GET('/users/{id}') + Future getUser(@Path('id') String id); + + // POST requests + @POST('/users') + Future createUser(@Body() CreateUserRequestData user); + + // PUT requests + @PUT('/users/{id}') + Future updateUser( + @Path('id') String id, + @Body() UpdateUserRequestData user, + ); + + // DELETE requests + @DELETE('/users/{id}') + Future deleteUser(@Path('id') String id); + + // File upload + @POST('/users/{id}/avatar') + @MultiPart() + Future uploadAvatar( + @Path('id') String id, + @Part(name: 'avatar') MultipartFile avatar, + ); + + // Custom headers + @GET('/users/me') + Future getCurrentUser( + @Header('Authorization') String authorization, + ); +} +``` + +#### Repository Mock for Testing +```dart +// lib/access/[feature]/mocks/[feature]_repository_mock.dart +import 'package:app/access/[feature]/[feature]_repository.dart'; +import 'package:app/access/[feature]/data/user_response_data.dart'; + +class MockUserRepository implements UserRepository { + static final List _mockUsers = [ + UserResponseData( + id: '1', + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@example.com', + avatarUrl: 'https://example.com/avatars/1.jpg', + createdAt: DateTime.now().subtract(Duration(days: 30)), + isActive: true, + ), + UserResponseData( + id: '2', + firstName: 'Jane', + lastName: 'Smith', + email: 'jane.smith@example.com', + avatarUrl: null, + createdAt: DateTime.now().subtract(Duration(days: 15)), + isActive: true, + ), + ]; + + @override + Future> getUsers({ + int page = 1, + int perPage = 20, + String? search, + String? sortBy, + String? sortOrder, + }) async { + // Simulate network delay + await Future.delayed(Duration(milliseconds: 500)); + + var filteredUsers = List.from(_mockUsers); + + // Apply search filter + if (search != null && search.isNotEmpty) { + filteredUsers = filteredUsers.where((user) => + user.firstName.toLowerCase().contains(search.toLowerCase()) || + user.lastName.toLowerCase().contains(search.toLowerCase()) || + user.email.toLowerCase().contains(search.toLowerCase()) + ).toList(); + } + + // Apply sorting + if (sortBy != null) { + filteredUsers.sort((a, b) { + int comparison = 0; + switch (sortBy) { + case 'first_name': + comparison = a.firstName.compareTo(b.firstName); + break; + case 'last_name': + comparison = a.lastName.compareTo(b.lastName); + break; + case 'email': + comparison = a.email.compareTo(b.email); + break; + case 'created_at': + comparison = a.createdAt.compareTo(b.createdAt); + break; + } + return sortOrder == 'desc' ? -comparison : comparison; + }); + } + + // Apply pagination + final startIndex = (page - 1) * perPage; + final endIndex = (startIndex + perPage).clamp(startIndex, filteredUsers.length); + final paginatedUsers = filteredUsers.sublist(startIndex, endIndex); + + return PaginatedResponse( + data: paginatedUsers, + meta: PaginationMeta( + page: page, + perPage: perPage, + total: filteredUsers.length, + totalPages: (filteredUsers.length / perPage).ceil(), + ), + ); + } + + @override + Future getUser(String id) async { + await Future.delayed(Duration(milliseconds: 300)); + + final user = _mockUsers.firstWhere( + (user) => user.id == id, + orElse: () => throw NotFoundException('User not found'), + ); + + return user; + } + + @override + Future createUser(CreateUserRequestData userData) async { + await Future.delayed(Duration(milliseconds: 800)); + + final newUser = UserResponseData( + id: DateTime.now().millisecondsSinceEpoch.toString(), + firstName: userData.firstName, + lastName: userData.lastName, + email: userData.email, + avatarUrl: null, + createdAt: DateTime.now(), + isActive: true, + ); + + _mockUsers.add(newUser); + return newUser; + } + + // Implement other methods... +} + +class NotFoundException implements Exception { + final String message; + const NotFoundException(this.message); +} +``` + +### 3. Error Handling + +#### Custom Exception Classes +```dart +// lib/access/exceptions/api_exceptions.dart +abstract class ApiException implements Exception { + final String message; + final int? statusCode; + final Map? details; + + const ApiException(this.message, {this.statusCode, this.details}); + + @override + String toString() => 'ApiException: $message'; +} + +class NetworkException extends ApiException { + const NetworkException(String message) : super(message); +} + +class ServerException extends ApiException { + const ServerException( + String message, { + int? statusCode, + Map? details, + }) : super(message, statusCode: statusCode, details: details); +} + +class ValidationException extends ApiException { + final Map> fieldErrors; + + const ValidationException( + String message, + this.fieldErrors, + ) : super(message); +} + +class UnauthorizedException extends ApiException { + const UnauthorizedException(String message) : super(message); +} + +class ForbiddenException extends ApiException { + const ForbiddenException(String message) : super(message); +} + +class NotFoundException extends ApiException { + const NotFoundException(String message) : super(message); +} + +class RateLimitException extends ApiException { + final Duration retryAfter; + + const RateLimitException( + String message, + this.retryAfter, + ) : super(message); +} +``` + +#### Dio Error Interceptor +```dart +// lib/access/interceptors/error_interceptor.dart +import 'package:dio/dio.dart'; +import 'package:logger/logger.dart'; +import 'package:app/access/exceptions/api_exceptions.dart'; + +class ErrorInterceptor extends Interceptor { + final Logger _logger; + + ErrorInterceptor(this._logger); + + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + final exception = _mapDioErrorToApiException(err); + + _logger.e( + 'API Error: ${err.requestOptions.method} ${err.requestOptions.path}', + exception, + err.stackTrace, + ); + + handler.reject(DioException( + requestOptions: err.requestOptions, + error: exception, + type: err.type, + response: err.response, + )); + } + + ApiException _mapDioErrorToApiException(DioException error) { + switch (error.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + return NetworkException('Connection timeout. Please check your internet connection.'); + + case DioExceptionType.badResponse: + return _handleResponseError(error.response!); + + case DioExceptionType.cancel: + return NetworkException('Request was cancelled.'); + + case DioExceptionType.connectionError: + return NetworkException('No internet connection available.'); + + case DioExceptionType.unknown: + return NetworkException('An unexpected error occurred: ${error.message}'); + + default: + return NetworkException('Network error occurred.'); + } + } + + ApiException _handleResponseError(Response response) { + final statusCode = response.statusCode!; + final data = response.data; + + switch (statusCode) { + case 400: + if (data is Map && data.containsKey('field_errors')) { + return ValidationException( + data['message'] ?? 'Validation failed', + Map>.from(data['field_errors']), + ); + } + return ServerException( + data['message'] ?? 'Bad request', + statusCode: statusCode, + details: data, + ); + + case 401: + return UnauthorizedException( + data['message'] ?? 'Authentication required', + ); + + case 403: + return ForbiddenException( + data['message'] ?? 'Access forbidden', + ); + + case 404: + return NotFoundException( + data['message'] ?? 'Resource not found', + ); + + case 429: + final retryAfter = Duration( + seconds: int.tryParse(response.headers['retry-after']?.first ?? '60') ?? 60, + ); + return RateLimitException( + data['message'] ?? 'Rate limit exceeded', + retryAfter, + ); + + case 500: + case 502: + case 503: + case 504: + return ServerException( + data['message'] ?? 'Server error occurred', + statusCode: statusCode, + details: data, + ); + + default: + return ServerException( + data['message'] ?? 'Unknown server error', + statusCode: statusCode, + details: data, + ); + } + } +} +``` + +### 4. Authentication & Authorization + +#### Authentication Interceptor +```dart +// lib/access/interceptors/auth_interceptor.dart +import 'package:dio/dio.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AuthInterceptor extends Interceptor { + final SharedPreferences _prefs; + static const String _tokenKey = 'auth_token'; + static const String _refreshTokenKey = 'refresh_token'; + + AuthInterceptor(this._prefs); + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + final token = _prefs.getString(_tokenKey); + + if (token != null) { + options.headers['Authorization'] = 'Bearer $token'; + } + + handler.next(options); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) async { + if (err.response?.statusCode == 401) { + // Try to refresh token + final refreshed = await _refreshToken(); + + if (refreshed) { + // Retry the original request + final options = err.requestOptions; + final token = _prefs.getString(_tokenKey); + options.headers['Authorization'] = 'Bearer $token'; + + try { + final dio = Dio(); + final response = await dio.fetch(options); + handler.resolve(response); + return; + } catch (e) { + // Token refresh failed, proceed with original error + } + } + } + + handler.next(err); + } + + Future _refreshToken() async { + try { + final refreshToken = _prefs.getString(_refreshTokenKey); + if (refreshToken == null) return false; + + final dio = Dio(); + final response = await dio.post( + '/auth/refresh', + data: {'refresh_token': refreshToken}, + ); + + final newToken = response.data['access_token']; + final newRefreshToken = response.data['refresh_token']; + + await _prefs.setString(_tokenKey, newToken); + await _prefs.setString(_refreshTokenKey, newRefreshToken); + + return true; + } catch (e) { + // Clear tokens on refresh failure + await _prefs.remove(_tokenKey); + await _prefs.remove(_refreshTokenKey); + return false; + } + } +} +``` + +### 5. Caching Strategy + +#### Cache Interceptor +```dart +// lib/access/interceptors/cache_interceptor.dart +import 'package:dio/dio.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:convert'; + +class CacheInterceptor extends Interceptor { + final SharedPreferences _prefs; + final Duration defaultCacheDuration; + + CacheInterceptor( + this._prefs, { + this.defaultCacheDuration = const Duration(minutes: 5), + }); + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + // Only cache GET requests + if (options.method.toUpperCase() != 'GET') { + handler.next(options); + return; + } + + final cacheKey = _getCacheKey(options); + final cachedResponse = _getCachedResponse(cacheKey); + + if (cachedResponse != null && !_isCacheExpired(cacheKey)) { + // Return cached response + handler.resolve(Response( + requestOptions: options, + data: cachedResponse['data'], + statusCode: cachedResponse['statusCode'], + headers: Headers.fromMap( + Map>.from(cachedResponse['headers']), + ), + )); + return; + } + + handler.next(options); + } + + @override + void onResponse(Response response, ResponseInterceptorHandler handler) { + // Cache successful GET responses + if (response.requestOptions.method.toUpperCase() == 'GET' && + response.statusCode == 200) { + final cacheKey = _getCacheKey(response.requestOptions); + _cacheResponse(cacheKey, response); + } + + handler.next(response); + } + + String _getCacheKey(RequestOptions options) { + final uri = options.uri.toString(); + final headers = options.headers.entries + .where((entry) => entry.key.startsWith('X-Cache')) + .map((entry) => '${entry.key}:${entry.value}') + .join(','); + + return 'cache_${uri}_$headers'.hashCode.toString(); + } + + Map? _getCachedResponse(String cacheKey) { + final cachedString = _prefs.getString(cacheKey); + if (cachedString == null) return null; + + try { + return jsonDecode(cachedString); + } catch (e) { + return null; + } + } + + bool _isCacheExpired(String cacheKey) { + final timestampString = _prefs.getString('${cacheKey}_timestamp'); + if (timestampString == null) return true; + + final timestamp = DateTime.tryParse(timestampString); + if (timestamp == null) return true; + + return DateTime.now().difference(timestamp) > defaultCacheDuration; + } + + void _cacheResponse(String cacheKey, Response response) { + final cacheData = { + 'data': response.data, + 'statusCode': response.statusCode, + 'headers': response.headers.map, + }; + + _prefs.setString(cacheKey, jsonEncode(cacheData)); + _prefs.setString('${cacheKey}_timestamp', DateTime.now().toIso8601String()); + } + + void clearCache() { + final keys = _prefs.getKeys().where((key) => key.startsWith('cache_')); + for (final key in keys) { + _prefs.remove(key); + } + } +} +``` + +### 6. Dio Configuration + +#### HTTP Client Setup +```dart +// lib/access/http/dio_client.dart +import 'package:dio/dio.dart'; +import 'package:logger/logger.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:app/access/interceptors/auth_interceptor.dart'; +import 'package:app/access/interceptors/cache_interceptor.dart'; +import 'package:app/access/interceptors/error_interceptor.dart'; + +class DioClient { + static const Duration _connectTimeout = Duration(seconds: 30); + static const Duration _receiveTimeout = Duration(seconds: 30); + static const Duration _sendTimeout = Duration(seconds: 30); + + static Dio createDioClient({ + required String baseUrl, + required SharedPreferences prefs, + required Logger logger, + Map? defaultHeaders, + bool enableCache = true, + Duration? cacheDuration, + }) { + final dio = Dio(BaseOptions( + baseUrl: baseUrl, + connectTimeout: _connectTimeout, + receiveTimeout: _receiveTimeout, + sendTimeout: _sendTimeout, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + ...?defaultHeaders, + }, + )); + + // Add interceptors + dio.interceptors.addAll([ + AuthInterceptor(prefs), + if (enableCache) + CacheInterceptor(prefs, defaultCacheDuration: cacheDuration ?? Duration(minutes: 5)), + ErrorInterceptor(logger), + if (logger.level == Level.debug) + LogInterceptor( + requestBody: true, + responseBody: true, + requestHeader: true, + responseHeader: false, + logPrint: (object) => logger.d(object), + ), + ]); + + return dio; + } +} +``` + +### 7. Service Layer Integration + +#### Service Implementation +```dart +// lib/business/[feature]/[feature]_service.dart +import 'dart:async'; +import 'package:get_it/get_it.dart'; +import 'package:logger/logger.dart'; +import 'package:rxdart/rxdart.dart'; + +import 'package:app/access/[feature]/[feature]_repository.dart'; +import 'package:app/access/exceptions/api_exceptions.dart'; +import 'package:app/business/[feature]/user.dart'; + +abstract interface class UserService implements Disposable { + factory UserService( + UserRepository repository, + Logger logger, + ) = _UserService; + + Stream> get usersStream; + Future> getUsers({ + int page = 1, + String? search, + String? sortBy, + }); + Future getUser(String id); + Future createUser(CreateUserRequest request); + Future updateUser(String id, UpdateUserRequest request); + Future deleteUser(String id); +} + +final class _UserService implements UserService { + final UserRepository _repository; + final Logger _logger; + final BehaviorSubject> _usersSubject = BehaviorSubject.seeded([]); + + _UserService(UserRepository repository, Logger logger) + : _repository = repository, + _logger = logger { + _loadInitialData(); + } + + @override + Stream> get usersStream => _usersSubject.stream; + + @override + Future> getUsers({ + int page = 1, + String? search, + String? sortBy, + }) async { + try { + _logger.d('Fetching users: page=$page, search=$search, sortBy=$sortBy'); + + final response = await _repository.getUsers( + page: page, + search: search, + sortBy: sortBy, + ); + + final users = response.data.map(_mapToUser).toList(); + + if (page == 1) { + // Replace current users for first page + _usersSubject.add(users); + } else { + // Append for pagination + final currentUsers = List.from(_usersSubject.value); + currentUsers.addAll(users); + _usersSubject.add(currentUsers); + } + + _logger.i('Successfully loaded ${users.length} users'); + return users; + + } on ApiException catch (e) { + _logger.e('API error while fetching users', e); + throw ServiceException('Failed to load users: ${e.message}', e); + } catch (e, stackTrace) { + _logger.e('Unexpected error while fetching users', e, stackTrace); + throw ServiceException('An unexpected error occurred while loading users'); + } + } + + @override + Future getUser(String id) async { + try { + _logger.d('Fetching user: $id'); + + final userData = await _repository.getUser(id); + final user = _mapToUser(userData); + + _logger.i('Successfully loaded user: ${user.email}'); + return user; + + } on NotFoundException catch (e) { + _logger.w('User not found: $id'); + throw ServiceException('User not found', e); + } on ApiException catch (e) { + _logger.e('API error while fetching user: $id', e); + throw ServiceException('Failed to load user: ${e.message}', e); + } catch (e, stackTrace) { + _logger.e('Unexpected error while fetching user: $id', e, stackTrace); + throw ServiceException('An unexpected error occurred while loading user'); + } + } + + @override + Future createUser(CreateUserRequest request) async { + try { + _logger.d('Creating user: ${request.email}'); + + final requestData = CreateUserRequestData( + firstName: request.firstName, + lastName: request.lastName, + email: request.email, + password: request.password, + ); + + final userData = await _repository.createUser(requestData); + final user = _mapToUser(userData); + + // Add to current users list + final currentUsers = List.from(_usersSubject.value); + currentUsers.insert(0, user); // Add at beginning + _usersSubject.add(currentUsers); + + _logger.i('Successfully created user: ${user.email}'); + return user; + + } on ValidationException catch (e) { + _logger.w('Validation failed for user creation: ${e.fieldErrors}'); + throw ServiceException('Invalid user data: ${e.message}', e); + } on ApiException catch (e) { + _logger.e('API error while creating user', e); + throw ServiceException('Failed to create user: ${e.message}', e); + } catch (e, stackTrace) { + _logger.e('Unexpected error while creating user', e, stackTrace); + throw ServiceException('An unexpected error occurred while creating user'); + } + } + + User _mapToUser(UserResponseData data) { + return User( + id: data.id, + firstName: data.firstName, + lastName: data.lastName, + email: data.email, + avatarUrl: data.avatarUrl, + createdAt: data.createdAt, + isActive: data.isActive, + ); + } + + Future _loadInitialData() async { + try { + await getUsers(); + } catch (e) { + _logger.w('Failed to load initial user data', e); + // Don't throw here - let the UI handle the empty state + } + } + + @override + FutureOr onDispose() async { + await _usersSubject.close(); + } +} + +// Service exception wrapper +class ServiceException implements Exception { + final String message; + final Exception? cause; + + const ServiceException(this.message, [this.cause]); + + @override + String toString() => 'ServiceException: $message'; +} + +// Request models for service layer +class CreateUserRequest { + final String firstName; + final String lastName; + final String email; + final String password; + + const CreateUserRequest({ + required this.firstName, + required this.lastName, + required this.email, + required this.password, + }); +} + +class UpdateUserRequest { + final String? firstName; + final String? lastName; + final String? email; + + const UpdateUserRequest({ + this.firstName, + this.lastName, + this.email, + }); +} +``` + +### 8. Dependency Injection Setup + +#### GetIt Registration +```dart +// lib/access/di/api_module.dart +import 'package:dio/dio.dart'; +import 'package:get_it/get_it.dart'; +import 'package:logger/logger.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:app/access/http/dio_client.dart'; +import 'package:app/access/[feature]/[feature]_repository.dart'; +import 'package:app/business/[feature]/[feature]_service.dart'; + +class ApiModule { + static Future register() async { + final getIt = GetIt.instance; + + // HTTP Client + getIt.registerSingleton( + DioClient.createDioClient( + baseUrl: 'https://api.example.com/v1', + prefs: getIt(), + logger: getIt(), + defaultHeaders: { + 'X-API-Version': '1.0', + 'X-Client-Platform': 'flutter', + }, + ), + ); + + // Repositories + getIt.registerSingleton( + UserRepository(getIt()), + ); + + // Services + getIt.registerSingleton( + UserService( + getIt(), + getIt(), + ), + ); + } +} +``` + +### 9. Testing API Integration + +#### Repository Tests +```dart +// test/access/user_repository_test.dart +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'user_repository_test.mocks.dart'; + +@GenerateMocks([Dio]) +void main() { + group('UserRepository', () { + late UserRepository repository; + late MockDio mockDio; + + setUp(() { + mockDio = MockDio(); + repository = UserRepository(mockDio); + }); + + group('getUsers', () { + test('should return paginated users on successful response', () async { + // Arrange + final responseData = { + 'data': [ + { + 'id': '1', + 'first_name': 'John', + 'last_name': 'Doe', + 'email': 'john@example.com', + 'created_at': '2023-01-01T00:00:00Z', + 'is_active': true, + } + ], + 'meta': { + 'page': 1, + 'per_page': 20, + 'total': 1, + 'total_pages': 1, + } + }; + + when(mockDio.get( + '/users', + queryParameters: anyNamed('queryParameters'), + )).thenAnswer((_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: '/users'), + )); + + // Act + final result = await repository.getUsers(); + + // Assert + expect(result.data, hasLength(1)); + expect(result.data.first.firstName, equals('John')); + expect(result.meta.total, equals(1)); + + verify(mockDio.get('/users', queryParameters: anyNamed('queryParameters'))).called(1); + }); + + test('should handle network errors', () async { + // Arrange + when(mockDio.get( + '/users', + queryParameters: anyNamed('queryParameters'), + )).thenThrow(DioException( + requestOptions: RequestOptions(path: '/users'), + type: DioExceptionType.connectionTimeout, + )); + + // Act & Assert + expect( + () => repository.getUsers(), + throwsA(isA()), + ); + }); + }); + + group('createUser', () { + test('should create user successfully', () async { + // Arrange + final requestData = CreateUserRequestData( + firstName: 'Jane', + lastName: 'Smith', + email: 'jane@example.com', + password: 'password123', + ); + + final responseData = { + 'id': '2', + 'first_name': 'Jane', + 'last_name': 'Smith', + 'email': 'jane@example.com', + 'created_at': '2023-01-01T00:00:00Z', + 'is_active': true, + }; + + when(mockDio.post( + '/users', + data: anyNamed('data'), + )).thenAnswer((_) async => Response( + data: responseData, + statusCode: 201, + requestOptions: RequestOptions(path: '/users'), + )); + + // Act + final result = await repository.createUser(requestData); + + // Assert + expect(result.firstName, equals('Jane')); + expect(result.email, equals('jane@example.com')); + + verify(mockDio.post('/users', data: anyNamed('data'))).called(1); + }); + }); + }); +} +``` + +#### Service Tests with API Mocking +```dart +// test/business/user_service_test.dart +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart'; + +import 'user_service_test.mocks.dart'; + +@GenerateMocks([UserRepository, Logger]) +void main() { + group('UserService', () { + late UserService service; + late MockUserRepository mockRepository; + late MockLogger mockLogger; + + setUp(() { + mockRepository = MockUserRepository(); + mockLogger = MockLogger(); + service = UserService(mockRepository, mockLogger); + }); + + tearDown(() async { + await service.onDispose(); + }); + + group('getUsers', () { + test('should transform repository data correctly', () async { + // Arrange + final repositoryResponse = PaginatedResponse( + data: [ + UserResponseData( + id: '1', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + createdAt: DateTime.parse('2023-01-01T00:00:00Z'), + isActive: true, + ), + ], + meta: PaginationMeta( + page: 1, + perPage: 20, + total: 1, + totalPages: 1, + ), + ); + + when(mockRepository.getUsers( + page: anyNamed('page'), + search: anyNamed('search'), + sortBy: anyNamed('sortBy'), + )).thenAnswer((_) async => repositoryResponse); + + // Act + final result = await service.getUsers(); + + // Assert + expect(result, hasLength(1)); + expect(result.first.firstName, equals('John')); + expect(result.first.fullName, equals('John Doe')); + + verify(mockRepository.getUsers( + page: 1, + search: null, + sortBy: null, + )).called(1); + }); + + test('should handle API exceptions gracefully', () async { + // Arrange + final apiException = NetworkException('Connection failed'); + when(mockRepository.getUsers( + page: anyNamed('page'), + search: anyNamed('search'), + sortBy: anyNamed('sortBy'), + )).thenThrow(apiException); + + // Act & Assert + expect( + () => service.getUsers(), + throwsA(isA()), + ); + + verify(mockLogger.e('API error while fetching users', apiException)).called(1); + }); + }); + + group('stream behavior', () { + test('should emit users when loaded successfully', () async { + // Arrange + final repositoryResponse = PaginatedResponse( + data: [ + UserResponseData( + id: '1', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + createdAt: DateTime.now(), + isActive: true, + ), + ], + meta: PaginationMeta(page: 1, perPage: 20, total: 1, totalPages: 1), + ); + + when(mockRepository.getUsers( + page: anyNamed('page'), + search: anyNamed('search'), + sortBy: anyNamed('sortBy'), + )).thenAnswer((_) async => repositoryResponse); + + // Act + final stream = service.usersStream; + await service.getUsers(); + + // Assert + await expectLater( + stream, + emits(predicate>((users) => + users.length == 1 && users.first.firstName == 'John' + )), + ); + }); + }); + }); +} +``` + +### 10. Performance Considerations + +#### Request Optimization +```dart +// Implement request batching for multiple related calls +class BatchRequestService { + final Dio _dio; + final Logger _logger; + + BatchRequestService(this._dio, this._logger); + + Future> batchUserRequests(List userIds) async { + try { + final futures = userIds.map((id) => _dio.get('/users/$id')); + final responses = await Future.wait(futures); + + final results = {}; + for (int i = 0; i < userIds.length; i++) { + results[userIds[i]] = responses[i].data; + } + + return results; + } catch (e) { + _logger.e('Batch request failed', e); + rethrow; + } + } +} + +// Implement request debouncing for search +class DebouncedSearchService { + final UserRepository _repository; + Timer? _debounceTimer; + + DebouncedSearchService(this._repository); + + Future> searchUsers(String query) { + final completer = Completer>(); + + _debounceTimer?.cancel(); + _debounceTimer = Timer(Duration(milliseconds: 300), () async { + try { + final response = await _repository.getUsers(search: query); + final users = response.data.map(_mapToUser).toList(); + completer.complete(users); + } catch (e) { + completer.completeError(e); + } + }); + + return completer.future; + } +} +``` + +## API Integration Checklist + +### Setup & Configuration +- [ ] **Data models** defined with proper JSON serialization +- [ ] **Repository interface** created with Retrofit annotations +- [ ] **Error handling** implemented with custom exceptions +- [ ] **Authentication** handled with interceptors +- [ ] **Caching strategy** implemented where appropriate +- [ ] **Dio client** configured with proper timeouts and headers + +### Error Handling +- [ ] **Network errors** properly mapped to user-friendly messages +- [ ] **HTTP status codes** handled appropriately +- [ ] **Validation errors** properly structured and displayed +- [ ] **Rate limiting** handled with retry logic +- [ ] **Authentication failures** trigger token refresh or re-login + +### Testing +- [ ] **Repository tests** cover success and error scenarios +- [ ] **Service tests** verify business logic and transformations +- [ ] **Mock implementations** available for development and testing +- [ ] **Integration tests** validate end-to-end API flows +- [ ] **Error scenarios** thoroughly tested + +### Performance +- [ ] **Request/response** sizes optimized +- [ ] **Caching** implemented for appropriate endpoints +- [ ] **Pagination** used for large datasets +- [ ] **Request batching** considered for multiple related calls +- [ ] **Background sync** implemented where needed + +### Security +- [ ] **Authentication tokens** properly stored and managed +- [ ] **Sensitive data** not logged or cached inappropriately +- [ ] **HTTPS** enforced for all API calls +- [ ] **Certificate pinning** considered for production +- [ ] **Input validation** performed before API calls + +## Common API Integration Anti-Patterns + +1. **Blocking UI Thread** - Always use async/await properly +2. **Poor Error Handling** - Don't ignore network errors +3. **Missing Loading States** - Always show loading indicators +4. **Overfetching Data** - Request only what you need +5. **No Offline Support** - Consider offline scenarios +6. **Hardcoded URLs** - Use configuration for different environments +7. **Missing Request Timeouts** - Always set appropriate timeouts +8. **Poor Cache Management** - Implement proper cache invalidation +9. **Insecure Token Storage** - Use secure storage for sensitive data +10. **No Request Retry Logic** - Implement retry for transient failures \ No newline at end of file diff --git a/.github/prompt-templates/bug-fix.md b/.github/prompt-templates/bug-fix.md new file mode 100644 index 00000000..e93d2cad --- /dev/null +++ b/.github/prompt-templates/bug-fix.md @@ -0,0 +1,487 @@ +# Bug Fix Template + +Use this template when debugging and fixing issues in the Flutter application. This template helps ensure systematic debugging and proper resolution following the project's established patterns. + +## Bug Information +**Issue Description**: [Describe the bug behavior] +**Expected Behavior**: [What should happen instead] +**Steps to Reproduce**: [List the steps to reproduce the issue] +**Environment**: [Flutter version, device, OS, etc.] +**Error Messages/Stack Traces**: [Include any error messages or stack traces] + +## Initial Investigation + +### 1. Identify the Layer +Determine which layer of the architecture is affected: +- [ ] **Presentation Layer** - UI issues, ViewModel problems, widget errors +- [ ] **Business Layer** - Service logic errors, data transformation issues +- [ ] **Access Layer** - Repository failures, API errors, persistence issues + +### 2. Gather Debug Information +- [ ] Enable detailed logging for the affected component + ```dart + // Add temporary debug logs + _logger.d('Debug: Current state - ${viewModel.currentState}'); + _logger.d('Debug: Input parameters - $parameters'); + ``` + +- [ ] Check error logs and analytics + ```dart + // Review Logger output and Firebase Crashlytics + // Check Bugsee for user session recordings + ``` + +- [ ] Reproduce the issue consistently + ```dart + // Create a minimal test case that reproduces the bug + testWidgets('reproduces the bug', (tester) async { + // Setup conditions that trigger the bug + await tester.pumpWidget(TestApp(child: ProblematicWidget())); + + // Perform actions that cause the issue + await tester.tap(find.byKey(Key('trigger-button'))); + await tester.pumpAndSettle(); + + // Verify the bug occurs + expect(find.text('Error'), findsOneWidget); + }); + ``` + +## Debugging Strategies by Layer + +### Presentation Layer Issues + +#### ViewModel State Problems +```dart +class DebuggingViewModel extends ViewModel { + // Add debug properties to track state changes + String get debugState => get("debugState", "initial"); + + void debugAction() { + _logger.d('Before action: debugState=$debugState'); + + // Your problematic code here + set("debugState", "updated"); + + _logger.d('After action: debugState=$debugState'); + } + + // Override to track property changes + @override + void set(String propertyName, T value) { + _logger.d('Property changed: $propertyName = $value'); + super.set(propertyName, value); + } +} +``` + +#### Widget Lifecycle Issues +```dart +class DebuggingWidget extends StatefulWidget { + @override + _DebuggingWidgetState createState() => _DebuggingWidgetState(); +} + +class _DebuggingWidgetState extends State { + @override + void initState() { + super.initState(); + debugPrint('Widget initState called'); + } + + @override + void dispose() { + debugPrint('Widget dispose called'); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + debugPrint('Widget build called'); + return Container(); // Your widget content + } +} +``` + +#### Stream/Async Issues +```dart +// Debug stream subscriptions +StreamSubscription? _debugSubscription; + +void _listenToStream() { + _debugSubscription = dataStream.listen( + (data) { + _logger.d('Stream data received: $data'); + }, + onError: (error) { + _logger.e('Stream error: $error'); + }, + onDone: () { + _logger.d('Stream completed'); + }, + ); +} +``` + +### Business Layer Issues + +#### Service Logic Problems +```dart +abstract interface class DebuggableService { + // Add debug methods to services + Map getDebugInfo(); + void enableDebugMode(bool enabled); +} + +final class _ServiceImpl implements DebuggableService { + bool _debugMode = false; + + @override + Map getDebugInfo() { + return { + 'debugMode': _debugMode, + 'lastAction': _lastAction, + 'internalState': _internalState, + }; + } + + Future problematicMethod() async { + if (_debugMode) { + _logger.d('Starting problematicMethod with state: ${getDebugInfo()}'); + } + + try { + // Your problematic code + final result = await _repository.getData(); + + if (_debugMode) { + _logger.d('Repository returned: $result'); + } + + } catch (error, stackTrace) { + _logger.e('Error in problematicMethod', error, stackTrace); + + if (_debugMode) { + _logger.d('Debug info at error: ${getDebugInfo()}'); + } + + rethrow; + } + } +} +``` + +#### Data Transformation Issues +```dart +// Add validation and debugging for data transformations +User transformUserData(UserData data) { + _logger.d('Transforming UserData: ${data.toJson()}'); + + // Validate input data + if (data.id.isEmpty) { + throw ArgumentError('UserData.id cannot be empty'); + } + + try { + final user = User.fromData(data); + _logger.d('Transformation successful: ${user.toString()}'); + return user; + } catch (error) { + _logger.e('Failed to transform UserData: ${data.toJson()}', error); + rethrow; + } +} +``` + +### Access Layer Issues + +#### Repository/API Problems +```dart +class DebuggableRepository { + final Dio _dio; + final Logger _logger; + + Future _makeRequest( + String method, + String path, + T Function(Map) parser, { + Map? data, + }) async { + _logger.d('API Request: $method $path'); + _logger.d('Request data: $data'); + + try { + final response = await _dio.request( + path, + options: Options(method: method), + data: data, + ); + + _logger.d('API Response status: ${response.statusCode}'); + _logger.d('API Response data: ${response.data}'); + + return parser(response.data); + + } on DioException catch (error) { + _logger.e('API Error: ${error.message}'); + _logger.e('Response data: ${error.response?.data}'); + + // Handle specific error types + switch (error.type) { + case DioExceptionType.connectionTimeout: + throw NetworkException('Connection timeout'); + case DioExceptionType.receiveTimeout: + throw NetworkException('Receive timeout'); + case DioExceptionType.badResponse: + throw ApiException('Server error: ${error.response?.statusCode}'); + default: + throw NetworkException('Network error: ${error.message}'); + } + } + } +} +``` + +#### Persistence Issues +```dart +class DebuggablePersistence { + final SharedPreferences _prefs; + final Logger _logger; + + Future getData(String key, T Function(String) parser) async { + try { + _logger.d('Reading data for key: $key'); + + final rawData = _prefs.getString(key); + if (rawData == null) { + _logger.d('No data found for key: $key'); + return null; + } + + _logger.d('Raw data found: $rawData'); + + final parsedData = parser(rawData); + _logger.d('Parsed data: $parsedData'); + + return parsedData; + + } catch (error) { + _logger.e('Failed to read data for key: $key', error); + return null; + } + } +} +``` + +## Testing the Fix + +### Create Regression Tests +```dart +// Create tests that verify the bug is fixed +group('Bug Fix - [Bug Description]', () { + test('should handle the problematic scenario correctly', () async { + // Arrange - Set up the exact conditions that caused the bug + final service = TestService(); + + // Act - Perform the action that previously caused the bug + final result = await service.problematicMethod(); + + // Assert - Verify the fix works + expect(result, isNotNull); + expect(result.isValid, isTrue); + }); + + test('should not break existing functionality', () async { + // Verify that fixing the bug didn't break other features + final service = TestService(); + + // Test normal scenarios still work + final normalResult = await service.normalMethod(); + expect(normalResult, isA()); + }); +}); +``` + +### Integration Testing +```dart +// Test the fix in a real app scenario +testWidgets('integration test for bug fix', (tester) async { + await tester.pumpWidget(TestApp()); + + // Simulate the exact user flow that caused the bug + await tester.tap(find.byKey(Key('problematic-button'))); + await tester.pumpAndSettle(); + + // Verify the fix works in the UI + expect(find.text('Error'), findsNothing); + expect(find.text('Success'), findsOneWidget); +}); +``` + +## Common Bug Patterns and Solutions + +### Memory Leaks +```dart +// Problem: Stream subscriptions not disposed +class ProblematicViewModel extends ViewModel { + StreamSubscription? _subscription; + + void startListening() { + _subscription = stream.listen(/* handler */); + } + + @override + void dispose() { + _subscription?.cancel(); // Fix: Always cancel subscriptions + super.dispose(); + } +} +``` + +### Null Safety Issues +```dart +// Problem: Unexpected nulls +String? getValue() => repository.getData()?.value; + +// Fix: Proper null handling +String getValue() { + final data = repository.getData(); + if (data == null) { + throw StateError('Data not available'); + } + return data.value ?? 'default_value'; +} +``` + +### Async Race Conditions +```dart +// Problem: Multiple async operations interfering +class ProblematicService { + Future loadData() async { + final data1 = await repository.getData1(); // Takes 2 seconds + final data2 = await repository.getData2(); // Takes 1 second + // Race condition if called multiple times + } + + // Fix: Use proper state management + bool _isLoading = false; + + Future loadData() async { + if (_isLoading) return; // Prevent concurrent calls + + try { + _isLoading = true; + final results = await Future.wait([ + repository.getData1(), + repository.getData2(), + ]); + processResults(results); + } finally { + _isLoading = false; + } + } +} +``` + +### Widget State Issues +```dart +// Problem: Widget rebuilt with stale data +class ProblematicWidget extends StatefulWidget { + @override + _ProblematicWidgetState createState() => _ProblematicWidgetState(); +} + +class _ProblematicWidgetState extends State { + String? data; + + @override + void initState() { + super.initState(); + loadData(); // Problem: Not handling async properly + } + + Future loadData() async { + final result = await repository.getData(); + setState(() { + data = result; // Fix: Check if widget is still mounted + }); + } + + // Fixed version: + Future loadDataFixed() async { + final result = await repository.getData(); + if (mounted) { // Fix: Check mounted state + setState(() { + data = result; + }); + } + } +} +``` + +## Performance Issues + +### Identifying Performance Problems +```dart +// Use Flutter Inspector and Performance overlay +class PerformanceDebuggingWidget extends StatelessWidget { + @override + Widget build(BuildContext context) { + return ListView.builder( // Problem: Not using builder for large lists + itemCount: items.length, + itemBuilder: (context, index) { + return ExpensiveWidget(items[index]); // Problem: Expensive widgets + }, + ); + } +} + +// Fix: Optimize expensive operations +class OptimizedWidget extends StatelessWidget { + @override + Widget build(BuildContext context) { + return ListView.builder( + itemCount: items.length, + itemBuilder: (context, index) { + return const OptimizedListItem(items[index]); // Use const + }, + ); + } +} +``` + +## Fix Implementation Checklist + +- [ ] **Root Cause Identified**: Understand why the bug occurred +- [ ] **Minimal Fix**: Make the smallest change possible to fix the issue +- [ ] **Regression Tests**: Add tests to prevent the bug from recurring +- [ ] **Code Review**: Have the fix reviewed by team members +- [ ] **Integration Testing**: Test the fix in the complete application flow +- [ ] **Performance Impact**: Verify the fix doesn't introduce performance issues +- [ ] **Documentation**: Update documentation if the fix changes behavior +- [ ] **Logging**: Add appropriate logging for future debugging + +## Post-Fix Validation + +### Manual Testing +- [ ] Test the exact scenario that was broken +- [ ] Test related functionality to ensure no regression +- [ ] Test edge cases that might be affected +- [ ] Verify error handling works correctly + +### Automated Testing +- [ ] All existing tests still pass +- [ ] New tests for the bug fix pass +- [ ] Integration tests validate the fix works end-to-end + +### Monitoring +- [ ] Deploy to staging environment first +- [ ] Monitor error rates and performance metrics +- [ ] Check user feedback and crash reports +- [ ] Validate the fix with real users before full rollout + +## Documentation +- [ ] Update relevant code comments +- [ ] Add changelog entry if appropriate +- [ ] Update technical documentation if needed +- [ ] Share learnings with the team to prevent similar issues \ No newline at end of file diff --git a/.github/prompt-templates/feature-development.md b/.github/prompt-templates/feature-development.md new file mode 100644 index 00000000..04f4222d --- /dev/null +++ b/.github/prompt-templates/feature-development.md @@ -0,0 +1,455 @@ +# Feature Development Template + +Use this template when creating new features in the Flutter application. This template follows the Clean Architecture + MVVM pattern established in the project. + +## Context +I need to create a new feature that follows the project's established patterns: +- MVVM architecture with custom ViewModel base class +- Clean Architecture with Access/Business/Presentation layers +- GetIt dependency injection +- Repository pattern for data access +- RxDart streams for reactive programming + +## Feature Requirements +**Feature Name**: [Specify the feature name] +**Description**: [Brief description of what the feature should do] +**User Stories**: [List the user stories this feature addresses] + +## Implementation Checklist + +### 1. Data Layer (Access) +- [ ] Create data models in `/lib/access/[feature]/data/` + ```dart + // Example: user_data.dart + @JsonSerializable() + class UserData { + final String id; + final String name; + final String email; + + const UserData({ + required this.id, + required this.name, + required this.email, + }); + + factory UserData.fromJson(Map json) => _$UserDataFromJson(json); + Map toJson() => _$UserDataToJson(this); + } + ``` + +- [ ] Create repository interface in `/lib/access/[feature]/` + ```dart + // Example: user_repository.dart + @RestApi(baseUrl: 'https://api.example.com/users') + abstract interface class UserRepository { + factory UserRepository(Dio dio, {String baseUrl}) = _UserRepository; + + @GET('/') + Future> getUsers(); + + @GET('/{id}') + Future getUser(@Path('id') String id); + + @POST('/') + Future createUser(@Body() UserData user); + } + ``` + +- [ ] Create mock repository for testing in `/lib/access/[feature]/mocks/` + ```dart + // Example: user_repository_mock.dart + class MockUserRepository implements UserRepository { + @override + Future> getUsers() async { + return [ + UserData(id: '1', name: 'John Doe', email: 'john@example.com'), + UserData(id: '2', name: 'Jane Smith', email: 'jane@example.com'), + ]; + } + } + ``` + +### 2. Business Layer +- [ ] Create domain model in `/lib/business/[feature]/` + ```dart + // Example: user.dart + class User extends Equatable { + final String id; + final String name; + final String email; + final bool isOnline; + + const User({ + required this.id, + required this.name, + required this.email, + this.isOnline = false, + }); + + factory User.fromData(UserData data) => User( + id: data.id, + name: data.name, + email: data.email, + ); + + User copyWith({ + String? id, + String? name, + String? email, + bool? isOnline, + }) => User( + id: id ?? this.id, + name: name ?? this.name, + email: email ?? this.email, + isOnline: isOnline ?? this.isOnline, + ); + + @override + List get props => [id, name, email, isOnline]; + } + ``` + +- [ ] Create service interface and implementation + ```dart + // Example: user_service.dart + abstract interface class UserService implements Disposable { + factory UserService( + UserRepository repository, + Logger logger, + ) = _UserService; + + Stream> get usersStream; + Future> getUsers(); + Future getUser(String id); + Future createUser(User user); + } + + final class _UserService implements UserService { + final BehaviorSubject> _usersSubject = BehaviorSubject(); + final UserRepository _repository; + final Logger _logger; + + _UserService(UserRepository repository, Logger logger) + : _repository = repository, + _logger = logger { + _loadInitialData(); + } + + @override + Stream> get usersStream => _usersSubject.stream; + + @override + Future> getUsers() async { + try { + _logger.d('Fetching users from repository'); + final userData = await _repository.getUsers(); + final users = userData.map(User.fromData).toList(); + _usersSubject.add(users); + _logger.i('Successfully loaded ${users.length} users'); + return users; + } catch (error, stackTrace) { + _logger.e('Failed to load users', error, stackTrace); + rethrow; + } + } + + Future _loadInitialData() async { + try { + await getUsers(); + } catch (error) { + _logger.w('Failed to load initial user data', error); + } + } + + @override + FutureOr onDispose() async { + await _usersSubject.close(); + } + } + ``` + +### 3. Presentation Layer +- [ ] Create ViewModel in `/lib/presentation/[feature]/` + ```dart + // Example: user_list_viewmodel.dart + class UserListViewModel extends ViewModel { + final _userService = GetIt.I(); + final _logger = GetIt.I(); + + Stream> get usersStream => + getLazy("usersStream", () => _userService.usersStream); + + bool get isLoading => get("isLoading", false); + String? get error => get("error", null); + + Future refreshUsers() async { + try { + set("isLoading", true); + set("error", null); + await _userService.getUsers(); + } catch (error) { + _logger.e('Failed to refresh users', error); + set("error", error.toString()); + } finally { + set("isLoading", false); + } + } + + Future createUser(String name, String email) async { + try { + set("isLoading", true); + final newUser = User( + id: '', // Will be assigned by server + name: name, + email: email, + ); + await _userService.createUser(newUser); + _logger.i('User created successfully'); + } catch (error) { + _logger.e('Failed to create user', error); + set("error", error.toString()); + } finally { + set("isLoading", false); + } + } + } + ``` + +- [ ] Create Page widget + ```dart + // Example: user_list_page.dart + final class UserListPage extends MvvmWidget { + const UserListPage({super.key}); + + @override + UserListViewModel getViewModel() => UserListViewModel(); + + @override + Widget build(BuildContext context, UserListViewModel viewModel) { + final local = context.local; + + return Scaffold( + appBar: AppBar( + title: Text(local.usersPageTitle), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: viewModel.refreshUsers, + ), + ], + ), + body: StreamBuilder>( + stream: viewModel.usersStream, + builder: (context, snapshot) { + if (snapshot.hasError) { + return ErrorWidget( + error: snapshot.error.toString(), + onRetry: viewModel.refreshUsers, + ); + } + + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator()); + } + + final users = snapshot.data!; + + if (users.isEmpty) { + return EmptyStateWidget( + message: local.noUsersFound, + actionText: local.refresh, + onAction: viewModel.refreshUsers, + ); + } + + return RefreshIndicator( + onRefresh: viewModel.refreshUsers, + child: ListView.builder( + itemCount: users.length, + itemBuilder: (context, index) { + final user = users[index]; + return UserListItem( + user: user, + onTap: () => _navigateToUserDetail(context, user.id), + ); + }, + ), + ); + }, + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _showCreateUserDialog(context, viewModel), + child: const Icon(Icons.add), + ), + ); + } + + void _navigateToUserDetail(BuildContext context, String userId) { + context.go('/users/$userId'); + } + + void _showCreateUserDialog(BuildContext context, UserListViewModel viewModel) { + // Implementation for create user dialog + } + } + ``` + +- [ ] Create reusable widgets (if needed) + ```dart + // Example: user_list_item.dart + class UserListItem extends StatelessWidget { + const UserListItem({ + super.key, + required this.user, + this.onTap, + }); + + final User user; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: CircleAvatar( + child: Text(user.name.substring(0, 1).toUpperCase()), + ), + title: Text(user.name), + subtitle: Text(user.email), + trailing: user.isOnline + ? const Icon(Icons.circle, color: Colors.green, size: 12) + : null, + onTap: onTap, + ); + } + } + ``` + +### 4. Dependency Registration +- [ ] Register services in GetIt configuration + ```dart + // In your DI setup file + GetIt.I.registerSingleton( + UserRepository(GetIt.I()), + ); + + GetIt.I.registerSingleton( + UserService( + GetIt.I(), + GetIt.I(), + ), + ); + ``` + +### 5. Routing +- [ ] Add routes to GoRouter configuration + ```dart + // In app_router.dart + GoRoute( + path: '/users', + builder: (context, state) => const UserListPage(), + ), + GoRoute( + path: '/users/:id', + builder: (context, state) { + final userId = state.pathParameters['id']!; + return UserDetailPage(userId: userId); + }, + ), + ``` + +### 6. Localization +- [ ] Add strings to localization files + ```arb + // In app_en.arb + "usersPageTitle": "Users", + "noUsersFound": "No users found", + "refresh": "Refresh", + "createUser": "Create User" + ``` + +### 7. Testing +- [ ] Write unit tests for service +- [ ] Write unit tests for ViewModel +- [ ] Write widget tests for Page +- [ ] Create integration tests if needed + +## Testing Implementation + +### Service Tests +```dart +// test/business/user_service_test.dart +group('UserService', () { + late UserService service; + late MockUserRepository mockRepository; + late MockLogger mockLogger; + + setUp(() { + mockRepository = MockUserRepository(); + mockLogger = MockLogger(); + service = UserService(mockRepository, mockLogger); + }); + + tearDown(() { + service.onDispose(); + }); + + test('should load users successfully', () async { + // Arrange + final expectedUsers = [ + UserData(id: '1', name: 'John', email: 'john@example.com'), + ]; + when(mockRepository.getUsers()).thenAnswer((_) async => expectedUsers); + + // Act + final result = await service.getUsers(); + + // Assert + expect(result, hasLength(1)); + expect(result.first.name, equals('John')); + verify(mockRepository.getUsers()).called(1); + }); +}); +``` + +### Widget Tests +```dart +// test/presentation/user_list_page_test.dart +testWidgets('UserListPage displays users correctly', (tester) async { + // Arrange + await tester.pumpWidget(TestApp(child: UserListPage())); + + // Act + await tester.pumpAndSettle(); + + // Assert + expect(find.byType(ListView), findsOneWidget); + expect(find.byType(UserListItem), findsWidgets); +}); +``` + +## Performance Considerations +- Use `const` constructors for widgets +- Implement proper stream disposal +- Use ListView.builder for large lists +- Consider pagination for large datasets +- Cache data appropriately +- Minimize API calls with proper state management + +## Common Issues to Avoid +- Forgetting to dispose of streams and subscriptions +- Not handling loading and error states +- Missing null safety checks +- Improper exception handling +- Not following the established naming conventions +- Skipping unit tests for business logic + +## Review Checklist +- [ ] Follows MVVM architecture pattern +- [ ] Implements proper error handling +- [ ] Includes loading states +- [ ] Has comprehensive tests +- [ ] Uses proper localization +- [ ] Follows established naming conventions +- [ ] Includes proper documentation +- [ ] Handles edge cases appropriately \ No newline at end of file diff --git a/.github/prompt-templates/performance-optimization.md b/.github/prompt-templates/performance-optimization.md new file mode 100644 index 00000000..146b7cce --- /dev/null +++ b/.github/prompt-templates/performance-optimization.md @@ -0,0 +1,1197 @@ +# Performance Optimization Template + +Use this template when analyzing and optimizing performance in the Flutter application. This template covers rendering performance, memory management, network optimization, and overall app responsiveness following the project's architecture patterns. + +## Performance Analysis Context +**Performance Issue**: [Describe the specific performance problem] +**Affected Area**: [UI rendering, network, memory, startup time, etc.] +**Impact**: [User experience impact, metrics affected] +**Target Metrics**: [Specific performance goals to achieve] + +## Performance Profiling Setup + +### Flutter Performance Tools +```dart +// Enable performance overlay in debug mode +class PerformanceDebugApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return MaterialApp( + showPerformanceOverlay: true, // Enable in debug mode only + checkerboardRasterCacheImages: true, // Highlight cached images + checkerboardOffscreenLayers: true, // Highlight expensive layers + home: MyHomePage(), + ); + } +} + +// Custom performance monitoring +class PerformanceMonitor { + static final Map _stopwatches = {}; + static final Logger _logger = GetIt.I(); + + static void startTimer(String operation) { + _stopwatches[operation] = Stopwatch()..start(); + } + + static void endTimer(String operation) { + final stopwatch = _stopwatches[operation]; + if (stopwatch != null) { + stopwatch.stop(); + _logger.d('Performance: $operation took ${stopwatch.elapsedMilliseconds}ms'); + _stopwatches.remove(operation); + } + } + + static T measure(String operation, T Function() function) { + startTimer(operation); + try { + return function(); + } finally { + endTimer(operation); + } + } + + static Future measureAsync(String operation, Future Function() function) async { + startTimer(operation); + try { + return await function(); + } finally { + endTimer(operation); + } + } +} +``` + +### Memory Profiling +```dart +// Memory usage tracking +class MemoryProfiler { + static void logMemoryUsage(String context) { + final rss = ProcessInfo.currentRss; + final maxRss = ProcessInfo.maxRss; + + debugPrint('Memory Usage [$context]: Current: ${rss ~/ 1024}KB, Max: ${maxRss ~/ 1024}KB'); + } + + static void trackWidgetMemory(Widget widget) { + WidgetsBinding.instance.addPostFrameCallback((_) { + logMemoryUsage('After ${widget.runtimeType} build'); + }); + } +} +``` + +## UI Performance Optimization + +### Widget Performance + +#### Optimized List Rendering +```dart +// Before: Inefficient list with all items in memory +class InefficientList extends StatelessWidget { + final List items; + + @override + Widget build(BuildContext context) { + return ListView( + children: items.map((item) => + ExpensiveListItem(item: item) // All widgets created at once + ).toList(), + ); + } +} + +// After: Optimized with ListView.builder and const widgets +class OptimizedList extends StatelessWidget { + final List items; + + const OptimizedList({super.key, required this.items}); + + @override + Widget build(BuildContext context) { + return ListView.builder( + itemCount: items.length, + itemExtent: 80.0, // Fixed height for better performance + itemBuilder: (context, index) { + final item = items[index]; + return OptimizedListItem( + key: ValueKey(item.id), // Proper keys for widget reuse + item: item, + ); + }, + ); + } +} + +class OptimizedListItem extends StatelessWidget { + final Item item; + + const OptimizedListItem({ + super.key, + required this.item, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: const Icon(Icons.person), // Const icon + title: Text(item.name), + subtitle: Text(item.email), + trailing: item.isOnline + ? const _OnlineIndicator() // Extract to const widget + : null, + ); + } +} + +class _OnlineIndicator extends StatelessWidget { + const _OnlineIndicator(); + + @override + Widget build(BuildContext context) { + return const Icon( + Icons.circle, + color: Colors.green, + size: 12, + ); + } +} +``` + +#### Efficient Widget Rebuilds +```dart +// Before: Inefficient widget that rebuilds everything +class InefficientWidget extends StatefulWidget { + @override + _InefficientWidgetState createState() => _InefficientWidgetState(); +} + +class _InefficientWidgetState extends State { + int counter = 0; + List expensiveData = []; + + @override + Widget build(BuildContext context) { + // Problem: Expensive computation on every build + final processedData = _processExpensiveData(expensiveData); + + return Column( + children: [ + Text('Counter: $counter'), + Text('Data: ${processedData.length}'), + ElevatedButton( + onPressed: () => setState(() => counter++), + child: Text('Increment'), + ), + ExpensiveWidget(data: processedData), // Rebuilds unnecessarily + ], + ); + } + + List _processExpensiveData(List data) { + // Expensive computation + return data.map((item) => item.toUpperCase()).toList(); + } +} + +// After: Optimized with memoization and selective rebuilds +class OptimizedWidget extends StatefulWidget { + @override + _OptimizedWidgetState createState() => _OptimizedWidgetState(); +} + +class _OptimizedWidgetState extends State { + int counter = 0; + List expensiveData = []; + late List _cachedProcessedData; + List? _lastExpensiveData; + + @override + void initState() { + super.initState(); + _updateProcessedData(); + } + + void _updateProcessedData() { + if (_lastExpensiveData != expensiveData) { + _cachedProcessedData = _processExpensiveData(expensiveData); + _lastExpensiveData = List.from(expensiveData); + } + } + + @override + Widget build(BuildContext context) { + _updateProcessedData(); + + return Column( + children: [ + // Counter section that rebuilds independently + _CounterSection( + counter: counter, + onIncrement: () => setState(() => counter++), + ), + + // Data section with cached processing + _DataSection(processedData: _cachedProcessedData), + + // Expensive widget with const constructor and memo + ExpensiveWidgetMemo(data: _cachedProcessedData), + ], + ); + } + + List _processExpensiveData(List data) { + return data.map((item) => item.toUpperCase()).toList(); + } +} + +class _CounterSection extends StatelessWidget { + final int counter; + final VoidCallback onIncrement; + + const _CounterSection({ + required this.counter, + required this.onIncrement, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Text('Counter: $counter'), + ElevatedButton( + onPressed: onIncrement, + child: const Text('Increment'), + ), + ], + ); + } +} + +// Memoized expensive widget +class ExpensiveWidgetMemo extends StatefulWidget { + final List data; + + const ExpensiveWidgetMemo({super.key, required this.data}); + + @override + State createState() => _ExpensiveWidgetMemoState(); +} + +class _ExpensiveWidgetMemoState extends State { + Widget? _cachedWidget; + List? _lastData; + + @override + Widget build(BuildContext context) { + // Only rebuild if data actually changed + if (_lastData != widget.data) { + _cachedWidget = _buildExpensiveContent(); + _lastData = List.from(widget.data); + } + + return _cachedWidget!; + } + + Widget _buildExpensiveContent() { + // Expensive widget building logic + return Container( + child: Column( + children: widget.data.map((item) => Text(item)).toList(), + ), + ); + } +} +``` + +### Image Performance + +#### Optimized Image Loading +```dart +class OptimizedNetworkImage extends StatelessWidget { + final String imageUrl; + final double? width; + final double? height; + final BoxFit fit; + + const OptimizedNetworkImage({ + super.key, + required this.imageUrl, + this.width, + this.height, + this.fit = BoxFit.cover, + }); + + @override + Widget build(BuildContext context) { + return Image.network( + imageUrl, + width: width, + height: height, + fit: fit, + // Performance optimizations + cacheWidth: width?.toInt(), + cacheHeight: height?.toInt(), + filterQuality: FilterQuality.medium, // Balance quality vs performance + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + + return SizedBox( + width: width, + height: height, + child: Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + ), + ), + ); + }, + errorBuilder: (context, error, stackTrace) { + return Container( + width: width, + height: height, + color: Colors.grey[300], + child: const Icon(Icons.error), + ); + }, + ); + } +} + +// Image caching service +class ImageCacheManager { + static final ImageCache _cache = PaintingBinding.instance.imageCache; + + static void optimizeCacheSize() { + // Optimize cache size based on available memory + _cache.maximumSize = 100; // Limit number of cached images + _cache.maximumSizeBytes = 50 * 1024 * 1024; // 50MB cache limit + } + + static void preloadImages(List imageUrls) { + for (final url in imageUrls) { + final image = NetworkImage(url); + precacheImage(image, navigatorKey.currentContext!); + } + } + + static void clearCache() { + _cache.clear(); + _cache.clearLiveImages(); + } +} +``` + +### Animation Performance + +#### Optimized Animations +```dart +// Efficient animation controller management +class OptimizedAnimationWidget extends StatefulWidget { + @override + _OptimizedAnimationWidgetState createState() => _OptimizedAnimationWidgetState(); +} + +class _OptimizedAnimationWidgetState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _fadeAnimation; + late Animation _slideAnimation; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + + // Use Tween.animate() for better performance + _fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeOut, + )); + + _slideAnimation = Tween( + begin: const Offset(0, 0.3), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeOut, + )); + } + + @override + void dispose() { + _controller.dispose(); // Always dispose animation controllers + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition( + position: _slideAnimation, + child: child, + ), + ); + }, + child: const _StaticContent(), // Child doesn't rebuild + ); + } +} + +class _StaticContent extends StatelessWidget { + const _StaticContent(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + child: const Text('Animated content'), + ); + } +} + +// Reusable animation mixin +mixin AnimationMixin on State, TickerProviderStateMixin { + late AnimationController animationController; + + @override + void initState() { + super.initState(); + animationController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + } + + @override + void dispose() { + animationController.dispose(); + super.dispose(); + } + + Animation createTweenAnimation( + T begin, + T end, { + Curve curve = Curves.easeInOut, + }) { + return Tween(begin: begin, end: end).animate( + CurvedAnimation(parent: animationController, curve: curve), + ); + } +} +``` + +## Memory Management + +### Stream and Subscription Management +```dart +// Proper stream disposal in ViewModels +class OptimizedViewModel extends ViewModel { + final List _subscriptions = []; + final CompositeSubscription _compositeSubscription = CompositeSubscription(); + + @override + void dispose() { + // Cancel all subscriptions + for (final subscription in _subscriptions) { + subscription.cancel(); + } + _subscriptions.clear(); + + // Cancel composite subscription + _compositeSubscription.cancel(); + + super.dispose(); + } + + void listenToStream(Stream stream, void Function(T) onData) { + final subscription = stream.listen(onData); + _subscriptions.add(subscription); + + // Alternative: Use composite subscription + _compositeSubscription.add(subscription); + } + + // Use getFromStream with proper cleanup + Stream> get itemsStream => getFromStream( + "itemsStream", + () => _service.itemsStream, + [], + ); +} + +// Composite subscription utility +class CompositeSubscription { + final List _subscriptions = []; + + void add(StreamSubscription subscription) { + _subscriptions.add(subscription); + } + + void cancel() { + for (final subscription in _subscriptions) { + subscription.cancel(); + } + _subscriptions.clear(); + } +} +``` + +### Efficient Data Structures +```dart +// Use appropriate data structures for performance +class OptimizedDataManager { + // Use Set for O(1) lookups + final Set _favoriteIds = {}; + + // Use Map for O(1) key-based access + final Map _itemsById = {}; + + // Use LinkedHashMap to maintain insertion order + final LinkedHashMap _recentItems = LinkedHashMap(); + + // Use Queue for FIFO operations + final Queue _searchHistory = Queue(); + + // Efficient favorite checking + bool isFavorite(String itemId) { + return _favoriteIds.contains(itemId); // O(1) operation + } + + // Efficient item lookup + Item? getItem(String id) { + return _itemsById[id]; // O(1) operation + } + + // Maintain bounded collections + void addToSearchHistory(String query) { + _searchHistory.addFirst(query); + + // Keep only last 10 searches + while (_searchHistory.length > 10) { + _searchHistory.removeLast(); + } + } + + // Efficient bulk updates + void updateItems(List items) { + // Clear and rebuild in batch for better performance + _itemsById.clear(); + + for (final item in items) { + _itemsById[item.id] = item; + } + } +} +``` + +### Object Pooling +```dart +// Object pool for expensive objects +class ObjectPool { + final List _pool = []; + final T Function() _factory; + final void Function(T)? _reset; + + ObjectPool(this._factory, {this.reset}); + + T acquire() { + if (_pool.isNotEmpty) { + final obj = _pool.removeLast(); + _reset?.call(obj); + return obj; + } + return _factory(); + } + + void release(T obj) { + if (_pool.length < 10) { // Limit pool size + _pool.add(obj); + } + } +} + +// Example usage for expensive widgets +class ExpensiveWidgetPool { + static final ObjectPool _pool = ObjectPool( + () => ExpensiveWidget(), + reset: (widget) => widget.reset(), + ); + + static ExpensiveWidget acquire() => _pool.acquire(); + static void release(ExpensiveWidget widget) => _pool.release(widget); +} +``` + +## Network Performance + +### Request Optimization +```dart +// Batch multiple API requests +class BatchApiService { + final Dio _dio; + final Duration _batchDelay = Duration(milliseconds: 100); + final Map>> _pendingRequests = {}; + + BatchApiService(this._dio); + + Future batchRequest( + String endpoint, + T Function(dynamic) parser, + ) async { + final completer = Completer(); + + _pendingRequests.putIfAbsent(endpoint, () => []).add(completer); + + // Process batch after delay + Timer(_batchDelay, () => _processBatch(endpoint, parser)); + + return completer.future; + } + + void _processBatch(String endpoint, T Function(dynamic) parser) async { + final completers = _pendingRequests.remove(endpoint); + if (completers == null || completers.isEmpty) return; + + try { + final response = await _dio.get(endpoint); + final result = parser(response.data); + + for (final completer in completers) { + completer.complete(result); + } + } catch (error) { + for (final completer in completers) { + completer.completeError(error); + } + } + } +} + +// Request deduplication +class RequestDeduplicator { + final Map> _activeRequests = {}; + + Future deduplicate( + String key, + Future Function() requestFactory, + ) { + if (_activeRequests.containsKey(key)) { + return _activeRequests[key]! as Future; + } + + final future = requestFactory(); + _activeRequests[key] = future; + + // Clean up after completion + future.whenComplete(() => _activeRequests.remove(key)); + + return future; + } +} +``` + +### Caching Optimization +```dart +// Intelligent cache management +class IntelligentCacheManager { + final SharedPreferences _prefs; + final Logger _logger; + final Map _cacheTimestamps = {}; + + IntelligentCacheManager(this._prefs, this._logger); + + Future get( + String key, + T Function(String) deserializer, { + Duration? maxAge, + }) async { + final cacheKey = 'cache_$key'; + final timestampKey = 'timestamp_$key'; + + final cachedData = _prefs.getString(cacheKey); + final timestampString = _prefs.getString(timestampKey); + + if (cachedData == null || timestampString == null) { + return null; + } + + final timestamp = DateTime.tryParse(timestampString); + if (timestamp == null) { + await _invalidateCache(key); + return null; + } + + final age = DateTime.now().difference(timestamp); + if (maxAge != null && age > maxAge) { + await _invalidateCache(key); + return null; + } + + try { + return deserializer(cachedData); + } catch (e) { + _logger.w('Failed to deserialize cached data for key: $key', e); + await _invalidateCache(key); + return null; + } + } + + Future set( + String key, + T data, + String Function(T) serializer, + ) async { + final cacheKey = 'cache_$key'; + final timestampKey = 'timestamp_$key'; + + try { + final serializedData = serializer(data); + await _prefs.setString(cacheKey, serializedData); + await _prefs.setString(timestampKey, DateTime.now().toIso8601String()); + _cacheTimestamps[key] = DateTime.now(); + } catch (e) { + _logger.e('Failed to cache data for key: $key', e); + } + } + + Future _invalidateCache(String key) async { + final cacheKey = 'cache_$key'; + final timestampKey = 'timestamp_$key'; + + await _prefs.remove(cacheKey); + await _prefs.remove(timestampKey); + _cacheTimestamps.remove(key); + } + + // Preemptive cache warming + Future warmCache(List keys) async { + for (final key in keys) { + // Trigger cache population in background + _warmCacheForKey(key); + } + } + + void _warmCacheForKey(String key) { + // Implementation depends on your data source + } +} +``` + +## Database Performance + +### Efficient Data Queries +```dart +// Optimized database operations +class OptimizedDatabaseService { + final Database _database; + + OptimizedDatabaseService(this._database); + + // Use prepared statements for repeated queries + Future>> getUsersByStatus(String status) async { + return await _database.rawQuery( + 'SELECT * FROM users WHERE status = ? ORDER BY created_at DESC LIMIT 50', + [status], + ); + } + + // Batch operations for better performance + Future batchInsertUsers(List users) async { + final batch = _database.batch(); + + for (final user in users) { + batch.insert('users', user.toMap()); + } + + await batch.commit(noResult: true); + } + + // Use indexes for frequently queried columns + Future createIndexes() async { + await _database.execute('CREATE INDEX IF NOT EXISTS idx_users_status ON users(status)'); + await _database.execute('CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at)'); + await _database.execute('CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)'); + } + + // Pagination for large datasets + Future> getUsersPaginated({ + required int page, + required int pageSize, + String? searchTerm, + }) async { + final offset = (page - 1) * pageSize; + + String query = 'SELECT * FROM users'; + List arguments = []; + + if (searchTerm != null && searchTerm.isNotEmpty) { + query += ' WHERE name LIKE ? OR email LIKE ?'; + arguments.addAll(['%$searchTerm%', '%$searchTerm%']); + } + + query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'; + arguments.addAll([pageSize, offset]); + + final results = await _database.rawQuery(query, arguments); + return results.map((row) => User.fromMap(row)).toList(); + } +} +``` + +## App Startup Performance + +### Optimized App Initialization +```dart +// Lazy initialization of services +class LazyServiceInitializer { + static final Map _services = {}; + static final Map> _initializing = {}; + + static T getService() { + if (_services.containsKey(T)) { + return _services[T] as T; + } + + throw StateError('Service $T not initialized. Call initializeService<$T>() first.'); + } + + static Future initializeService(Future Function() factory) async { + if (_services.containsKey(T)) { + return _services[T] as T; + } + + if (_initializing.containsKey(T)) { + return await _initializing[T]! as T; + } + + final future = factory(); + _initializing[T] = future; + + try { + final service = await future; + _services[T] = service; + return service; + } finally { + _initializing.remove(T); + } + } +} + +// Parallel service initialization +class ParallelInitializer { + static Future initializeApp() async { + final stopwatch = Stopwatch()..start(); + + // Initialize critical services first + await _initializeCriticalServices(); + + // Initialize non-critical services in parallel + await _initializeNonCriticalServices(); + + stopwatch.stop(); + debugPrint('App initialization took: ${stopwatch.elapsedMilliseconds}ms'); + } + + static Future _initializeCriticalServices() async { + // These must be initialized before the app can start + await Future.wait([ + LazyServiceInitializer.initializeService( + () async => await SharedPreferences.getInstance(), + ), + LazyServiceInitializer.initializeService( + () async => Logger(), + ), + ]); + } + + static Future _initializeNonCriticalServices() async { + // These can be initialized in background + unawaited(Future.wait([ + LazyServiceInitializer.initializeService( + () async => await Firebase.initializeApp(), + ), + LazyServiceInitializer.initializeService( + () async => await PackageInfo.fromPlatform(), + ), + _preloadCriticalAssets(), + ])); + } + + static Future _preloadCriticalAssets() async { + // Preload images and other assets + final context = navigatorKey.currentContext!; + await Future.wait([ + precacheImage(AssetImage('assets/images/logo.png'), context), + precacheImage(AssetImage('assets/images/placeholder.png'), context), + ]); + } +} +``` + +## Performance Testing and Monitoring + +### Performance Benchmarking +```dart +// Performance benchmark suite +class PerformanceBenchmark { + static final Logger _logger = GetIt.I(); + + static Future runBenchmarks() async { + await _benchmarkListScrolling(); + await _benchmarkDataProcessing(); + await _benchmarkNetworkRequests(); + await _benchmarkMemoryUsage(); + } + + static Future _benchmarkListScrolling() async { + final stopwatch = Stopwatch()..start(); + + // Simulate list with 1000 items + final items = List.generate(1000, (index) => Item(id: '$index', name: 'Item $index')); + + // Measure rendering time + // This would need to be integrated with widget testing + + stopwatch.stop(); + _logger.i('List scrolling benchmark: ${stopwatch.elapsedMilliseconds}ms'); + } + + static Future _benchmarkDataProcessing() async { + final stopwatch = Stopwatch()..start(); + + // Simulate data processing + final data = List.generate(10000, (index) => 'Item $index'); + final processed = data.map((item) => item.toUpperCase()).toList(); + + stopwatch.stop(); + _logger.i('Data processing benchmark: ${stopwatch.elapsedMilliseconds}ms'); + + assert(stopwatch.elapsedMilliseconds < 100, 'Data processing too slow'); + } + + static Future _benchmarkNetworkRequests() async { + final dio = GetIt.I(); + final stopwatch = Stopwatch()..start(); + + try { + await dio.get('/api/health'); + stopwatch.stop(); + _logger.i('Network request benchmark: ${stopwatch.elapsedMilliseconds}ms'); + + assert(stopwatch.elapsedMilliseconds < 2000, 'Network request too slow'); + } catch (e) { + _logger.w('Benchmark network request failed', e); + } + } + + static Future _benchmarkMemoryUsage() async { + final beforeMemory = ProcessInfo.currentRss; + + // Create temporary objects + final largeList = List.generate(100000, (index) => 'Data $index'); + + final afterMemory = ProcessInfo.currentRss; + final memoryIncrease = afterMemory - beforeMemory; + + _logger.i('Memory usage increase: ${memoryIncrease ~/ 1024}KB'); + + // Clean up + largeList.clear(); + + assert(memoryIncrease < 50 * 1024 * 1024, 'Memory usage too high'); // 50MB limit + } +} +``` + +### Real-time Performance Monitoring +```dart +// Performance metrics collector +class PerformanceMetrics { + static final Map> _metrics = {}; + static Timer? _reportingTimer; + + static void initialize() { + _reportingTimer = Timer.periodic( + Duration(minutes: 5), + (_) => _reportMetrics(), + ); + } + + static void recordMetric(String name, int value) { + _metrics.putIfAbsent(name, () => []).add(value); + + // Keep only last 100 measurements + final values = _metrics[name]!; + if (values.length > 100) { + values.removeAt(0); + } + } + + static void _reportMetrics() { + final report = {}; + + for (final entry in _metrics.entries) { + final values = entry.value; + if (values.isNotEmpty) { + final average = values.reduce((a, b) => a + b) / values.length; + final max = values.reduce((a, b) => a > b ? a : b); + final min = values.reduce((a, b) => a < b ? a : b); + + report[entry.key] = { + 'average': average.round(), + 'max': max, + 'min': min, + 'count': values.length, + }; + } + } + + // Send metrics to analytics service + GetIt.I().i('Performance metrics: $report'); + + // Optional: Send to remote analytics + // FirebaseAnalytics.instance.logEvent( + // name: 'performance_metrics', + // parameters: report, + // ); + } + + static void dispose() { + _reportingTimer?.cancel(); + _metrics.clear(); + } +} + +// Frame rate monitoring +class FrameRateMonitor { + static int _frameCount = 0; + static DateTime _lastTime = DateTime.now(); + static final ValueNotifier _fpsNotifier = ValueNotifier(0.0); + + static ValueNotifier get fpsNotifier => _fpsNotifier; + + static void initialize() { + WidgetsBinding.instance.addPostFrameCallback(_onFrame); + } + + static void _onFrame(Duration timestamp) { + _frameCount++; + final now = DateTime.now(); + final elapsed = now.difference(_lastTime); + + if (elapsed.inSeconds >= 1) { + final fps = _frameCount / elapsed.inSeconds; + _fpsNotifier.value = fps; + + PerformanceMetrics.recordMetric('fps', fps.round()); + + _frameCount = 0; + _lastTime = now; + } + + WidgetsBinding.instance.addPostFrameCallback(_onFrame); + } +} +``` + +## Performance Optimization Checklist + +### UI Performance +- [ ] **ListView.builder** used for large lists instead of ListView +- [ ] **const constructors** used wherever possible +- [ ] **Widget keys** properly implemented for list items +- [ ] **RepaintBoundary** used to isolate expensive widgets +- [ ] **Image caching** optimized with appropriate cache sizes +- [ ] **Animation controllers** properly disposed +- [ ] **Expensive computations** moved off the main thread + +### Memory Management +- [ ] **Stream subscriptions** properly cancelled in dispose() +- [ ] **Large objects** released when no longer needed +- [ ] **Image cache** size limited appropriately +- [ ] **Object pools** used for frequently created/destroyed objects +- [ ] **Memory leaks** identified and fixed +- [ ] **Efficient data structures** chosen for use cases + +### Network Performance +- [ ] **Request batching** implemented where appropriate +- [ ] **Response caching** configured correctly +- [ ] **Request deduplication** prevents redundant calls +- [ ] **Connection pooling** optimized +- [ ] **Timeout values** set appropriately +- [ ] **Retry logic** implemented for transient failures + +### App Startup +- [ ] **Critical services** initialized first +- [ ] **Non-critical services** initialized in background +- [ ] **Asset preloading** optimized +- [ ] **Splash screen** covers initialization time +- [ ] **Deep links** handled efficiently + +### Database Performance +- [ ] **Database indexes** created for frequently queried columns +- [ ] **Batch operations** used for bulk data changes +- [ ] **Query optimization** applied to slow queries +- [ ] **Pagination** implemented for large result sets +- [ ] **Connection management** optimized + +## Common Performance Anti-Patterns + +1. **Building expensive widgets on every frame** +2. **Not disposing of controllers and subscriptions** +3. **Using ListView instead of ListView.builder for large lists** +4. **Missing const constructors** +5. **Performing synchronous operations on the main thread** +6. **Not optimizing image loading and caching** +7. **Creating new objects unnecessarily in build methods** +8. **Poor state management causing excessive rebuilds** +9. **Not using appropriate data structures** +10. **Ignoring memory leaks and unbounded growth** + +## Performance Monitoring in Production + +### Setup Performance Tracking +```dart +// Firebase Performance Monitoring integration +class PerformanceTracker { + static HttpMetric? _currentHttpMetric; + + static void startTrace(String traceName) { + FirebasePerformance.instance.newTrace(traceName).start(); + } + + static void stopTrace(String traceName) { + // Implementation depends on how you store trace references + } + + static void trackHttpRequest(String url, String method) { + _currentHttpMetric = FirebasePerformance.instance.newHttpMetric(url, method); + _currentHttpMetric?.start(); + } + + static void finishHttpRequest(int responseCode, int responseSize) { + _currentHttpMetric?.responseCode = responseCode; + _currentHttpMetric?.responsePayloadSize = responseSize; + _currentHttpMetric?.stop(); + _currentHttpMetric = null; + } +} +``` + +This comprehensive performance optimization template provides actionable strategies for improving Flutter app performance across all areas while following the project's established architecture patterns. \ No newline at end of file diff --git a/.github/prompt-templates/refactoring.md b/.github/prompt-templates/refactoring.md new file mode 100644 index 00000000..9a3ac572 --- /dev/null +++ b/.github/prompt-templates/refactoring.md @@ -0,0 +1,690 @@ +# Refactoring Template + +Use this template when refactoring existing code to improve maintainability, performance, or architecture while preserving functionality. This template ensures safe refactoring practices within the MVVM + Clean Architecture pattern. + +## Refactoring Context +**Target Code/Component**: [Specify what needs to be refactored] +**Refactoring Goal**: [Improve performance, maintainability, readability, etc.] +**Scope**: [Files, classes, or methods affected] +**Risk Level**: [Low/Medium/High - based on complexity and impact] + +## Pre-Refactoring Analysis + +### 1. Document Current Behavior +- [ ] **Identify all public interfaces** that must remain unchanged +- [ ] **List current functionality** that must be preserved +- [ ] **Document dependencies** and coupling with other components +- [ ] **Catalog existing tests** that validate current behavior + +### 2. Identify Code Smells +Common patterns to look for in this codebase: + +#### Architecture Violations +```dart +// Problem: Business logic in ViewModel +class BadViewModel extends ViewModel { + Future complexBusinessLogic() async { + // Business logic should be in Service layer + final data = await repository.getData(); + final processed = data.map((item) => { + // Complex transformation logic here + }).toList(); + } +} + +// Solution: Move to Service layer +class GoodViewModel extends ViewModel { + final _service = GetIt.I(); + + Future triggerBusinessLogic() async { + await _service.processData(); // Delegate to service + } +} +``` + +#### Dependency Issues +```dart +// Problem: Direct instantiation instead of DI +class BadService { + final repository = ConcreteRepository(); // Hard dependency + + Future doWork() async { + final data = await repository.getData(); + } +} + +// Solution: Use dependency injection +abstract interface class DataService { + factory DataService(DataRepository repository, Logger logger) = _DataService; +} + +final class _DataService implements DataService { + final DataRepository _repository; + final Logger _logger; + + _DataService(DataRepository repository, Logger logger) + : _repository = repository, _logger = logger; +} +``` + +#### Performance Issues +```dart +// Problem: Inefficient stream usage +class BadViewModel extends ViewModel { + Stream> get items => Stream.fromFuture( + repository.getItems(), // New API call on every access + ); +} + +// Solution: Cached stream with proper lifecycle +class GoodViewModel extends ViewModel { + Stream> get items => getFromStream( + "items", + () => _service.itemsStream, // Cached stream from service + [], + ); +} +``` + +### 3. Plan the Refactoring Strategy + +#### Small Steps Approach +Break down large refactoring into smaller, testable changes: + +1. **Extract Method/Class** - Separate concerns into focused units +2. **Move Method** - Relocate methods to appropriate layers +3. **Replace Dependencies** - Introduce interfaces and DI +4. **Optimize Performance** - Improve algorithms and caching +5. **Clean Up** - Remove dead code and improve naming + +## Refactoring Patterns by Layer + +### Presentation Layer Refactoring + +#### ViewModel Simplification +```dart +// Before: Complex ViewModel with mixed concerns +class ComplexViewModel extends ViewModel { + final _repository = GetIt.I(); + final _logger = GetIt.I(); + + Future loadAndProcessData() async { + try { + set("isLoading", true); + + // Problem: Complex business logic in ViewModel + final rawData = await _repository.getData(); + final processedData = rawData.where((item) => item.isValid) + .map((item) => TransformedData( + id: item.id, + displayName: item.name.toUpperCase(), + formattedDate: DateFormat('yyyy-MM-dd').format(item.date), + // More complex transformations... + )) + .toList(); + + set("data", processedData); + _logger.i("Data loaded and processed: ${processedData.length} items"); + + } catch (error) { + _logger.e("Failed to load data", error); + set("error", error.toString()); + } finally { + set("isLoading", false); + } + } +} + +// After: Simplified ViewModel with proper separation +class RefactoredViewModel extends ViewModel { + final _dataService = GetIt.I(); + + Stream> get dataStream => getFromStream( + "dataStream", + () => _dataService.processedDataStream, + [], + ); + + bool get isLoading => get("isLoading", false); + String? get error => get("error", null); + + Future refreshData() async { + try { + set("isLoading", true); + set("error", null); + await _dataService.refreshData(); + } catch (error) { + set("error", error.toString()); + } finally { + set("isLoading", false); + } + } +} +``` + +#### Widget Decomposition +```dart +// Before: Large, monolithic widget +class LargeWidget extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Complex Screen'), + actions: [ + IconButton(icon: Icon(Icons.search), onPressed: () {}), + IconButton(icon: Icon(Icons.filter), onPressed: () {}), + PopupMenuButton(/* complex menu implementation */), + ], + ), + body: Column( + children: [ + // Complex header section + Container(/* complex header UI */), + + // Complex filter section + Row(/* complex filter UI */), + + // Complex list section + Expanded( + child: ListView.builder(/* complex list implementation */), + ), + ], + ), + floatingActionButton: FloatingActionButton(/* implementation */), + ); + } +} + +// After: Decomposed into focused widgets +class RefactoredScreen extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: _buildAppBar(context), + body: const Column( + children: [ + ScreenHeader(), + FilterSection(), + Expanded(child: DataList()), + ], + ), + floatingActionButton: const AddButton(), + ); + } + + PreferredSizeWidget _buildAppBar(BuildContext context) { + return AppBar( + title: const Text('Complex Screen'), + actions: const [ + SearchButton(), + FilterButton(), + OptionsMenu(), + ], + ); + } +} + +// Extract focused widgets +class ScreenHeader extends StatelessWidget { + const ScreenHeader({super.key}); + + @override + Widget build(BuildContext context) { + // Focused header implementation + return Container(/* header UI */); + } +} +``` + +### Business Layer Refactoring + +#### Service Decomposition +```dart +// Before: God service with multiple responsibilities +class MonolithicService { + Future manageUserData() async { + // User authentication + await authenticate(); + + // Data fetching + final userData = await fetchUserData(); + + // Data processing + final processedData = processUserData(userData); + + // Analytics tracking + trackUserActivity(processedData); + + // Notifications + sendNotifications(processedData); + } +} + +// After: Decomposed into focused services +abstract interface class AuthenticationService { + Future authenticate(); + Future isAuthenticated(); +} + +abstract interface class UserDataService { + Future fetchUserData(); + Future processUserData(RawUserData raw); + Stream get userDataStream; +} + +abstract interface class AnalyticsService { + Future trackUserActivity(UserData data); +} + +abstract interface class NotificationService { + Future sendNotifications(UserData data); +} + +// Coordinator service for complex workflows +abstract interface class UserManagementService { + factory UserManagementService( + AuthenticationService auth, + UserDataService userData, + AnalyticsService analytics, + NotificationService notifications, + ) = _UserManagementService; + + Future initializeUser(); +} +``` + +#### State Management Optimization +```dart +// Before: Inefficient state management +class InefficientService { + final BehaviorSubject> _allItems = BehaviorSubject.seeded([]); + final BehaviorSubject _filter = BehaviorSubject.seeded(''); + + Stream> get filteredItems => Rx.combineLatest2( + _allItems.stream, + _filter.stream, + (List items, String filter) { + // Problem: Filtering happens on every emission + return items.where((item) => + item.name.toLowerCase().contains(filter.toLowerCase()) + ).toList(); + }, + ); +} + +// After: Optimized with caching and debouncing +class OptimizedService { + final BehaviorSubject> _allItems = BehaviorSubject.seeded([]); + final BehaviorSubject _filter = BehaviorSubject.seeded(''); + final BehaviorSubject> _cachedFilteredItems = BehaviorSubject.seeded([]); + + late final StreamSubscription _filterSubscription; + + OptimizedService() { + // Debounce filter changes to avoid excessive processing + _filterSubscription = Rx.combineLatest2( + _allItems.stream, + _filter.stream.debounceTime(Duration(milliseconds: 300)), + _applyFilter, + ).listen(_cachedFilteredItems.add); + } + + Stream> get filteredItems => _cachedFilteredItems.stream; + + List _applyFilter(List items, String filter) { + if (filter.isEmpty) return items; + + final lowerFilter = filter.toLowerCase(); + return items.where((item) => + item.name.toLowerCase().contains(lowerFilter) + ).toList(); + } +} +``` + +### Access Layer Refactoring + +#### Repository Pattern Improvements +```dart +// Before: Repository with mixed concerns +class MixedRepository { + final Dio _dio; + final SharedPreferences _prefs; + + Future> getItems() async { + // Problem: Caching logic mixed with data fetching + final cached = _prefs.getString('cached_items'); + if (cached != null) { + final items = (jsonDecode(cached) as List) + .map((json) => Item.fromJson(json)) + .toList(); + + // Problem: Complex cache validation logic + if (items.isNotEmpty && + DateTime.now().difference(items.first.timestamp).inHours < 1) { + return items; + } + } + + final response = await _dio.get('/items'); + final items = (response.data as List) + .map((json) => Item.fromJson(json)) + .toList(); + + // Problem: Serialization mixed with business logic + await _prefs.setString('cached_items', jsonEncode( + items.map((item) => item.toJson()).toList(), + )); + + return items; + } +} + +// After: Separated concerns with cache abstraction +abstract interface class ItemRepository { + Future> getItems(); + Future refreshItems(); +} + +abstract interface class CacheManager { + Future get(String key); + Future set(String key, T value, {Duration? ttl}); + Future isValid(String key); +} + +final class _ItemRepository implements ItemRepository { + final ItemApiClient _apiClient; + final CacheManager> _cache; + final Logger _logger; + + static const _cacheKey = 'items'; + static const _cacheDuration = Duration(hours: 1); + + @override + Future> getItems() async { + try { + // Try cache first + if (await _cache.isValid(_cacheKey)) { + final cached = await _cache.get(_cacheKey); + if (cached != null) { + _logger.d('Returning cached items: ${cached.length}'); + return cached; + } + } + + // Fetch from API + return await refreshItems(); + + } catch (error) { + _logger.e('Failed to get items', error); + rethrow; + } + } + + @override + Future> refreshItems() async { + try { + final items = await _apiClient.getItems(); + await _cache.set(_cacheKey, items, ttl: _cacheDuration); + _logger.i('Refreshed items: ${items.length}'); + return items; + } catch (error) { + _logger.e('Failed to refresh items', error); + rethrow; + } + } +} +``` + +## Testing During Refactoring + +### Characterization Tests +Create tests that capture current behavior before refactoring: + +```dart +// Create comprehensive tests for existing behavior +group('Characterization Tests - Before Refactoring', () { + late LegacyService service; + + setUp(() { + service = LegacyService(); + }); + + test('should handle normal data processing', () async { + // Document exact current behavior + final input = createTestData(); + final result = await service.processData(input); + + expect(result.length, equals(5)); + expect(result.first.status, equals('processed')); + expect(result.last.timestamp, isNotNull); + }); + + test('should handle edge cases', () async { + // Document edge case behavior + final emptyResult = await service.processData([]); + expect(emptyResult, isEmpty); + + final nullResult = await service.processData(null); + expect(nullResult, isNull); + }); +}); +``` + +### Refactoring Tests +Ensure refactored code maintains the same behavior: + +```dart +group('Refactored Service Tests', () { + late RefactoredService service; + + setUp(() { + service = RefactoredService(); + }); + + test('maintains same data processing behavior', () async { + // Same test as characterization test + final input = createTestData(); + final result = await service.processData(input); + + expect(result.length, equals(5)); + expect(result.first.status, equals('processed')); + expect(result.last.timestamp, isNotNull); + }); + + test('improves performance while maintaining correctness', () async { + final stopwatch = Stopwatch()..start(); + + final result = await service.processLargeDataset(createLargeTestData()); + + stopwatch.stop(); + + // Verify functionality is preserved + expect(result, isNotEmpty); + + // Verify performance improvement + expect(stopwatch.elapsedMilliseconds, lessThan(1000)); + }); +}); +``` + +## Performance Optimization Refactoring + +### Widget Performance +```dart +// Before: Inefficient widget rebuilds +class InefficientList extends StatelessWidget { + final List items; + + @override + Widget build(BuildContext context) { + return ListView( + children: items.map((item) => + ListTile( + title: Text(item.name), + subtitle: Text(DateFormat('yyyy-MM-dd').format(item.date)), // Problem: Formatting on every build + onTap: () => Navigator.push(/* expensive navigation */), + ) + ).toList(), + ); + } +} + +// After: Optimized with const widgets and cached formatting +class OptimizedList extends StatelessWidget { + final List items; + + const OptimizedList({super.key, required this.items}); + + @override + Widget build(BuildContext context) { + return ListView.builder( // Use builder for better performance + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return OptimizedListItem( + key: ValueKey(item.id), // Proper keys for widget reuse + item: item, + ); + }, + ); + } +} + +class OptimizedListItem extends StatelessWidget { + final Item item; + + const OptimizedListItem({ + super.key, + required this.item, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(item.name), + subtitle: Text(item.formattedDate), // Pre-formatted in model + onTap: () => context.go('/items/${item.id}'), // Use GoRouter + ); + } +} +``` + +### Memory Optimization +```dart +// Before: Memory leaks and inefficient caching +class LeakyService { + final Map _subscriptions = {}; + final Map _cache = {}; // Grows indefinitely + + void subscribeToData(String key) { + _subscriptions[key] = dataStream.listen((data) { + _cache[key] = data; // Problem: No size limit or expiration + }); + } +} + +// After: Proper resource management +class OptimizedService implements Disposable { + final Map _subscriptions = {}; + final LRUCache _cache = LRUCache(maxSize: 100); + late final Timer _cleanupTimer; + + OptimizedService() { + // Periodic cleanup + _cleanupTimer = Timer.periodic( + Duration(minutes: 5), + (_) => _performCleanup(), + ); + } + + void subscribeToData(String key) { + // Cancel existing subscription + _subscriptions[key]?.cancel(); + + _subscriptions[key] = dataStream.listen((data) { + _cache.put(key, data); // LRU cache with size limits + }); + } + + void _performCleanup() { + // Remove expired cache entries + _cache.removeExpired(); + + // Cancel unused subscriptions + _subscriptions.removeWhere((key, subscription) { + if (!_isSubscriptionNeeded(key)) { + subscription.cancel(); + return true; + } + return false; + }); + } + + @override + FutureOr onDispose() async { + _cleanupTimer.cancel(); + + for (final subscription in _subscriptions.values) { + await subscription.cancel(); + } + _subscriptions.clear(); + _cache.clear(); + } +} +``` + +## Refactoring Checklist + +### Before Starting +- [ ] **Comprehensive test coverage** exists for current functionality +- [ ] **Backup current implementation** (create feature branch) +- [ ] **Document current behavior** and interfaces +- [ ] **Identify breaking changes** and plan migration strategy + +### During Refactoring +- [ ] **Make small, incremental changes** +- [ ] **Run tests after each change** +- [ ] **Maintain backward compatibility** where possible +- [ ] **Update documentation** as you go +- [ ] **Use automated refactoring tools** when available + +### After Refactoring +- [ ] **All tests pass** (both existing and new) +- [ ] **Performance benchmarks** meet or exceed previous implementation +- [ ] **Code review** by team members +- [ ] **Integration testing** with full application +- [ ] **Update dependent code** if interfaces changed + +## Risk Mitigation + +### High-Risk Refactoring +For major architectural changes: +- Use **Strangler Fig Pattern** - gradually replace old system +- Implement **Feature Flags** - allow rollback if issues arise +- Create **Adapter Patterns** - ease transition between old and new +- Plan **Gradual Migration** - refactor incrementally over time + +### Rollback Strategy +- Keep old implementation available during transition +- Use feature flags to switch between implementations +- Monitor performance and error rates closely +- Have automated rollback triggers for critical issues + +## Common Refactoring Anti-Patterns to Avoid + +1. **Big Bang Refactoring** - Don't refactor everything at once +2. **Refactoring Without Tests** - Always have comprehensive test coverage first +3. **Changing Behavior** - Refactoring should preserve functionality +4. **Ignoring Performance** - Monitor performance impact during refactoring +5. **Breaking Interfaces** - Maintain backward compatibility when possible + +## Documentation Updates + +After successful refactoring: +- [ ] Update architecture documentation +- [ ] Revise API documentation +- [ ] Update code comments and inline docs +- [ ] Create migration guides if needed +- [ ] Share refactoring learnings with team \ No newline at end of file diff --git a/.github/prompt-templates/testing.md b/.github/prompt-templates/testing.md new file mode 100644 index 00000000..db3b5bba --- /dev/null +++ b/.github/prompt-templates/testing.md @@ -0,0 +1,1191 @@ +# Testing Template + +Use this template when writing comprehensive tests for the Flutter application. This template covers unit, widget, and integration testing following the project's established patterns and architecture. + +## Testing Context +**Component to Test**: [Specify the class, service, or feature being tested] +**Test Type**: [Unit/Widget/Integration/Performance] +**Test Goals**: [What behavior or functionality to verify] +**Risk Level**: [Critical/High/Medium/Low - based on business impact] + +## Test Strategy by Layer + +### Access Layer Testing (Repositories & Data) + +#### Repository Tests +```dart +// test/access/example_repository_test.dart +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'example_repository_test.mocks.dart'; + +@GenerateMocks([Dio]) +void main() { + group('ExampleRepository', () { + late ExampleRepository repository; + late MockDio mockDio; + + setUp(() { + mockDio = MockDio(); + repository = ExampleRepository(mockDio); + }); + + group('getData', () { + test('should return data when API call succeeds', () async { + // Arrange + final expectedResponse = Response>( + data: {'items': [{'id': '1', 'name': 'Test'}]}, + statusCode: 200, + requestOptions: RequestOptions(path: '/test'), + ); + + when(mockDio.get('/data')).thenAnswer((_) async => expectedResponse); + + // Act + final result = await repository.getData(); + + // Assert + expect(result, isA()); + expect(result.items, hasLength(1)); + expect(result.items.first.name, equals('Test')); + verify(mockDio.get('/data')).called(1); + }); + + test('should throw NetworkException when API call fails', () async { + // Arrange + when(mockDio.get('/data')).thenThrow( + DioException( + requestOptions: RequestOptions(path: '/data'), + type: DioExceptionType.connectionTimeout, + ), + ); + + // Act & Assert + expect( + () => repository.getData(), + throwsA(isA()), + ); + verify(mockDio.get('/data')).called(1); + }); + + test('should handle malformed response data', () async { + // Arrange + final malformedResponse = Response>( + data: {'invalid': 'structure'}, + statusCode: 200, + requestOptions: RequestOptions(path: '/test'), + ); + + when(mockDio.get('/data')).thenAnswer((_) async => malformedResponse); + + // Act & Assert + expect( + () => repository.getData(), + throwsA(isA()), + ); + }); + }); + + group('createData', () { + test('should create data successfully', () async { + // Arrange + final inputData = CreateDataRequest(name: 'New Item'); + final expectedResponse = Response>( + data: {'id': '123', 'name': 'New Item', 'status': 'created'}, + statusCode: 201, + requestOptions: RequestOptions(path: '/test'), + ); + + when(mockDio.post('/data', data: inputData.toJson())) + .thenAnswer((_) async => expectedResponse); + + // Act + final result = await repository.createData(inputData); + + // Assert + expect(result.id, equals('123')); + expect(result.name, equals('New Item')); + expect(result.status, equals('created')); + verify(mockDio.post('/data', data: inputData.toJson())).called(1); + }); + }); + }); +} +``` + +#### Data Model Tests +```dart +// test/access/data/user_data_test.dart +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('UserData', () { + group('fromJson', () { + test('should parse valid JSON correctly', () { + // Arrange + final json = { + 'id': '123', + 'name': 'John Doe', + 'email': 'john@example.com', + 'created_at': '2023-01-01T00:00:00Z', + }; + + // Act + final userData = UserData.fromJson(json); + + // Assert + expect(userData.id, equals('123')); + expect(userData.name, equals('John Doe')); + expect(userData.email, equals('john@example.com')); + expect(userData.createdAt, isA()); + }); + + test('should handle missing optional fields', () { + // Arrange + final json = { + 'id': '123', + 'name': 'John Doe', + 'email': 'john@example.com', + // missing created_at + }; + + // Act + final userData = UserData.fromJson(json); + + // Assert + expect(userData.id, equals('123')); + expect(userData.createdAt, isNull); + }); + + test('should throw when required fields are missing', () { + // Arrange + final json = { + 'id': '123', + // missing required name field + 'email': 'john@example.com', + }; + + // Act & Assert + expect( + () => UserData.fromJson(json), + throwsA(isA()), + ); + }); + }); + + group('toJson', () { + test('should serialize to JSON correctly', () { + // Arrange + final userData = UserData( + id: '123', + name: 'John Doe', + email: 'john@example.com', + createdAt: DateTime.parse('2023-01-01T00:00:00Z'), + ); + + // Act + final json = userData.toJson(); + + // Assert + expect(json['id'], equals('123')); + expect(json['name'], equals('John Doe')); + expect(json['email'], equals('john@example.com')); + expect(json['created_at'], equals('2023-01-01T00:00:00.000Z')); + }); + }); + }); +} +``` + +### Business Layer Testing (Services) + +#### Service Tests with Mocking +```dart +// test/business/example_service_test.dart +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart'; + +import 'example_service_test.mocks.dart'; + +@GenerateMocks([ExampleRepository, Logger]) +void main() { + group('ExampleService', () { + late ExampleService service; + late MockExampleRepository mockRepository; + late MockLogger mockLogger; + + setUp(() { + mockRepository = MockExampleRepository(); + mockLogger = MockLogger(); + service = ExampleService(mockRepository, mockLogger); + }); + + tearDown(() async { + await service.onDispose(); + }); + + group('getData', () { + test('should transform repository data correctly', () async { + // Arrange + final repositoryData = ExampleResponseData( + items: [ + ExampleItemData(id: '1', name: 'Item 1', value: 100), + ExampleItemData(id: '2', name: 'Item 2', value: 200), + ], + ); + + when(mockRepository.getData()).thenAnswer((_) async => repositoryData); + + // Act + final result = await service.getData(); + + // Assert + expect(result, hasLength(2)); + expect(result.first.id, equals('1')); + expect(result.first.displayName, equals('Item 1')); + expect(result.first.formattedValue, equals('\$100.00')); + + verify(mockRepository.getData()).called(1); + verify(mockLogger.d('Fetching data from repository')).called(1); + verify(mockLogger.i('Successfully transformed 2 items')).called(1); + }); + + test('should handle empty repository response', () async { + // Arrange + final emptyResponse = ExampleResponseData(items: []); + when(mockRepository.getData()).thenAnswer((_) async => emptyResponse); + + // Act + final result = await service.getData(); + + // Assert + expect(result, isEmpty); + verify(mockRepository.getData()).called(1); + verify(mockLogger.i('No items found')).called(1); + }); + + test('should handle repository errors gracefully', () async { + // Arrange + final error = NetworkException('Connection failed'); + when(mockRepository.getData()).thenThrow(error); + + // Act & Assert + expect( + () => service.getData(), + throwsA(isA()), + ); + + verify(mockRepository.getData()).called(1); + verify(mockLogger.e('Failed to fetch data', error)).called(1); + }); + }); + + group('dataStream', () { + test('should emit data when service loads successfully', () async { + // Arrange + final repositoryData = ExampleResponseData( + items: [ExampleItemData(id: '1', name: 'Test', value: 100)], + ); + when(mockRepository.getData()).thenAnswer((_) async => repositoryData); + + // Act + final stream = service.dataStream; + await service.getData(); // Trigger data load + + // Assert + await expectLater( + stream, + emits(predicate>((items) => + items.length == 1 && items.first.id == '1' + )), + ); + }); + + test('should handle stream errors', () async { + // Arrange + final error = NetworkException('Connection failed'); + when(mockRepository.getData()).thenThrow(error); + + // Act + final stream = service.dataStream; + + // Trigger error by calling getData + try { + await service.getData(); + } catch (_) {} // Expect this to throw + + // Assert - Stream should not emit error items + await expectLater( + stream.take(1), + emits(isEmpty), // Should emit empty list initially + ); + }); + }); + + group('createItem', () { + test('should create item and update stream', () async { + // Arrange + final newItem = ExampleItem( + id: '', + displayName: 'New Item', + formattedValue: '\$50.00', + ); + + final createdItemData = ExampleItemData( + id: '123', + name: 'New Item', + value: 50, + ); + + when(mockRepository.createItem(any)) + .thenAnswer((_) async => createdItemData); + + // Setup initial data + when(mockRepository.getData()).thenAnswer((_) async => + ExampleResponseData(items: [createdItemData]) + ); + + // Act + final result = await service.createItem(newItem); + + // Assert + expect(result.id, equals('123')); + verify(mockRepository.createItem(any)).called(1); + verify(mockLogger.i('Item created successfully: 123')).called(1); + + // Verify stream is updated + await expectLater( + service.dataStream.take(1), + emits(predicate>((items) => + items.any((item) => item.id == '123') + )), + ); + }); + }); + }); +} +``` + +### Presentation Layer Testing (ViewModels & Widgets) + +#### ViewModel Tests +```dart +// test/presentation/example_viewmodel_test.dart +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; + +import 'example_viewmodel_test.mocks.dart'; + +@GenerateMocks([ExampleService]) +void main() { + group('ExampleViewModel', () { + late ExampleViewModel viewModel; + late MockExampleService mockService; + + setUp(() { + mockService = MockExampleService(); + + // Setup GetIt for dependency injection + GetIt.I.reset(); + GetIt.I.registerSingleton(mockService); + + viewModel = ExampleViewModel(); + }); + + tearDown(() { + viewModel.dispose(); + GetIt.I.reset(); + }); + + group('initialization', () { + test('should initialize with default values', () { + // Assert + expect(viewModel.isLoading, isFalse); + expect(viewModel.error, isNull); + }); + + test('should setup stream subscription', () { + // Arrange + final testData = [ + ExampleItem(id: '1', displayName: 'Test', formattedValue: '\$100'), + ]; + + when(mockService.dataStream) + .thenAnswer((_) => Stream.value(testData)); + + // Act + final stream = viewModel.dataStream; + + // Assert + expect(stream, emits(testData)); + verify(mockService.dataStream).called(1); + }); + }); + + group('refreshData', () { + test('should update loading state during refresh', () async { + // Arrange + when(mockService.getData()).thenAnswer((_) async { + await Future.delayed(Duration(milliseconds: 100)); + return []; + }); + + // Act + final future = viewModel.refreshData(); + + // Assert - Should be loading during operation + expect(viewModel.isLoading, isTrue); + expect(viewModel.error, isNull); + + await future; + + // Assert - Should not be loading after completion + expect(viewModel.isLoading, isFalse); + expect(viewModel.error, isNull); + }); + + test('should handle service errors correctly', () async { + // Arrange + final error = ServiceException('Service failed'); + when(mockService.getData()).thenThrow(error); + + // Act + await viewModel.refreshData(); + + // Assert + expect(viewModel.isLoading, isFalse); + expect(viewModel.error, equals('Service failed')); + verify(mockService.getData()).called(1); + }); + + test('should clear previous errors on successful refresh', () async { + // Arrange - First set an error state + when(mockService.getData()).thenThrow(ServiceException('First error')); + await viewModel.refreshData(); + expect(viewModel.error, isNotNull); + + // Act - Now succeed + when(mockService.getData()).thenAnswer((_) async => []); + await viewModel.refreshData(); + + // Assert + expect(viewModel.error, isNull); + expect(viewModel.isLoading, isFalse); + }); + }); + + group('createItem', () { + test('should create item successfully', () async { + // Arrange + final newItem = ExampleItem( + id: '', + displayName: 'New Item', + formattedValue: '\$75.00', + ); + + final createdItem = ExampleItem( + id: '123', + displayName: 'New Item', + formattedValue: '\$75.00', + ); + + when(mockService.createItem(newItem)) + .thenAnswer((_) async => createdItem); + + // Act + await viewModel.createItem('New Item', 75.0); + + // Assert + expect(viewModel.isLoading, isFalse); + expect(viewModel.error, isNull); + verify(mockService.createItem(any)).called(1); + }); + + test('should validate input parameters', () async { + // Act & Assert + expect( + () => viewModel.createItem('', 75.0), + throwsA(isA()), + ); + + expect( + () => viewModel.createItem('Valid Name', -10.0), + throwsA(isA()), + ); + + verifyNever(mockService.createItem(any)); + }); + }); + + group('property change notifications', () { + test('should notify listeners when loading state changes', () { + // Arrange + var notificationCount = 0; + viewModel.addListener(() => notificationCount++); + + when(mockService.getData()).thenAnswer((_) async { + await Future.delayed(Duration(milliseconds: 50)); + return []; + }); + + // Act + viewModel.refreshData(); + + // Assert - Should notify when loading starts + expect(notificationCount, greaterThan(0)); + }); + + test('should notify listeners when error state changes', () async { + // Arrange + var notificationCount = 0; + viewModel.addListener(() => notificationCount++); + + when(mockService.getData()).thenThrow(ServiceException('Error')); + + // Act + await viewModel.refreshData(); + + // Assert + expect(notificationCount, greaterThan(0)); + expect(viewModel.error, equals('Error')); + }); + }); + }); +} +``` + +#### Widget Tests +```dart +// test/presentation/example_page_test.dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; +import 'package:mockito/mockito.dart'; +import 'package:get_it/get_it.dart'; + +import '../test_app.dart'; +import 'example_page_test.mocks.dart'; + +@GenerateMocks([ExampleService]) +void main() { + group('ExamplePage Widget Tests', () { + late MockExampleService mockService; + + setUp(() { + mockService = MockExampleService(); + GetIt.I.reset(); + GetIt.I.registerSingleton(mockService); + }); + + tearDown(() { + GetIt.I.reset(); + }); + + testWidgets('should display loading indicator initially', (tester) async { + // Arrange + when(mockService.dataStream) + .thenAnswer((_) => Stream.value([])); + + // Act + await tester.pumpWidget(TestApp(child: ExamplePage())); + + // Assert + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('should display data when loaded', (tester) async { + // Arrange + final testData = [ + ExampleItem(id: '1', displayName: 'Test Item 1', formattedValue: '\$100'), + ExampleItem(id: '2', displayName: 'Test Item 2', formattedValue: '\$200'), + ]; + + when(mockService.dataStream) + .thenAnswer((_) => Stream.value(testData)); + + // Act + await tester.pumpWidget(TestApp(child: ExamplePage())); + await tester.pumpAndSettle(); + + // Assert + expect(find.byType(ListView), findsOneWidget); + expect(find.text('Test Item 1'), findsOneWidget); + expect(find.text('Test Item 2'), findsOneWidget); + expect(find.text('\$100'), findsOneWidget); + expect(find.text('\$200'), findsOneWidget); + }); + + testWidgets('should display error message on error', (tester) async { + // Arrange + when(mockService.dataStream) + .thenAnswer((_) => Stream.error(ServiceException('Network error'))); + + // Act + await tester.pumpWidget(TestApp(child: ExamplePage())); + await tester.pumpAndSettle(); + + // Assert + expect(find.text('Network error'), findsOneWidget); + expect(find.byType(ElevatedButton), findsOneWidget); // Retry button + }); + + testWidgets('should display empty state when no data', (tester) async { + // Arrange + when(mockService.dataStream) + .thenAnswer((_) => Stream.value([])); + + // Act + await tester.pumpWidget(TestApp(child: ExamplePage())); + await tester.pumpAndSettle(); + + // Assert + expect(find.text('No items found'), findsOneWidget); + expect(find.byIcon(Icons.inbox), findsOneWidget); + }); + + testWidgets('should handle refresh action', (tester) async { + // Arrange + when(mockService.dataStream) + .thenAnswer((_) => Stream.value([])); + when(mockService.getData()) + .thenAnswer((_) async => []); + + // Act + await tester.pumpWidget(TestApp(child: ExamplePage())); + await tester.pumpAndSettle(); + + // Find and tap refresh button + await tester.tap(find.byIcon(Icons.refresh)); + await tester.pumpAndSettle(); + + // Assert + verify(mockService.getData()).called(1); + }); + + testWidgets('should navigate to detail page when item tapped', (tester) async { + // Arrange + final testData = [ + ExampleItem(id: '1', displayName: 'Test Item', formattedValue: '\$100'), + ]; + + when(mockService.dataStream) + .thenAnswer((_) => Stream.value(testData)); + + // Act + await tester.pumpWidget(TestApp(child: ExamplePage())); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(ListTile)); + await tester.pumpAndSettle(); + + // Assert - Check navigation occurred + // This depends on your routing setup + expect(find.text('Detail Page'), findsOneWidget); + }); + + testWidgets('should show create dialog when FAB tapped', (tester) async { + // Arrange + when(mockService.dataStream) + .thenAnswer((_) => Stream.value([])); + + // Act + await tester.pumpWidget(TestApp(child: ExamplePage())); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + + // Assert + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text('Create New Item'), findsOneWidget); + }); + + group('Accessibility Tests', () { + testWidgets('should have proper semantic labels', (tester) async { + // Arrange + final testData = [ + ExampleItem(id: '1', displayName: 'Test Item', formattedValue: '\$100'), + ]; + + when(mockService.dataStream) + .thenAnswer((_) => Stream.value(testData)); + + // Act + await tester.pumpWidget(TestApp(child: ExamplePage())); + await tester.pumpAndSettle(); + + // Assert + expect(find.bySemanticsLabel('Items list'), findsOneWidget); + expect(find.bySemanticsLabel('Refresh items'), findsOneWidget); + expect(find.bySemanticsLabel('Add new item'), findsOneWidget); + }); + + testWidgets('should support screen reader navigation', (tester) async { + // Test semantic traversal order and labels + final testData = [ + ExampleItem(id: '1', displayName: 'Item 1', formattedValue: '\$100'), + ExampleItem(id: '2', displayName: 'Item 2', formattedValue: '\$200'), + ]; + + when(mockService.dataStream) + .thenAnswer((_) => Stream.value(testData)); + + await tester.pumpWidget(TestApp(child: ExamplePage())); + await tester.pumpAndSettle(); + + final semantics = tester.getSemantics(find.byType(Scaffold)); + expect(semantics, hasA11yFocus()); + }); + }); + }); +} +``` + +#### Custom Widget Component Tests +```dart +// test/presentation/components/example_list_item_test.dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; + +void main() { + group('ExampleListItem', () { + testWidgets('should display item information correctly', (tester) async { + // Arrange + final item = ExampleItem( + id: '1', + displayName: 'Test Item', + formattedValue: '\$100.00', + ); + + bool tapped = false; + + // Act + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ExampleListItem( + item: item, + onTap: () => tapped = true, + ), + ), + ), + ); + + // Assert + expect(find.text('Test Item'), findsOneWidget); + expect(find.text('\$100.00'), findsOneWidget); + + // Test interaction + await tester.tap(find.byType(ExampleListItem)); + expect(tapped, isTrue); + }); + + testWidgets('should handle long text gracefully', (tester) async { + // Arrange + final item = ExampleItem( + id: '1', + displayName: 'Very Long Item Name That Should Be Truncated', + formattedValue: '\$1,000,000.00', + ); + + // Act + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 200, // Constrained width + child: ExampleListItem(item: item), + ), + ), + ), + ); + + // Assert - Text should be truncated with ellipsis + final textWidget = tester.widget(find.text(contains('Very Long'))); + expect(textWidget.overflow, equals(TextOverflow.ellipsis)); + }); + + testWidgets('should apply correct theme styles', (tester) async { + // Arrange + final item = ExampleItem( + id: '1', + displayName: 'Test Item', + formattedValue: '\$100.00', + ); + + // Act + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.light(), + home: Scaffold( + body: ExampleListItem(item: item), + ), + ), + ); + + // Assert theme application + final listTile = tester.widget(find.byType(ListTile)); + expect(listTile.textColor, isNotNull); + }); + }); +} +``` + +### Integration Testing + +#### Full Feature Integration Tests +```dart +// integration_test/example_feature_test.dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:app/main.dart' as app; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Example Feature Integration', () { + testWidgets('complete user workflow', (tester) async { + // Start the app + app.main(); + await tester.pumpAndSettle(); + + // Navigate to example feature + await tester.tap(find.text('Examples')); + await tester.pumpAndSettle(); + + // Wait for data to load + await tester.pumpAndSettle(Duration(seconds: 2)); + + // Verify initial state + expect(find.byType(ListView), findsOneWidget); + + // Test create functionality + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + + // Fill create form + await tester.enterText(find.byKey(Key('name_field')), 'Integration Test Item'); + await tester.enterText(find.byKey(Key('value_field')), '150.00'); + + await tester.tap(find.text('Create')); + await tester.pumpAndSettle(Duration(seconds: 2)); + + // Verify item was created + expect(find.text('Integration Test Item'), findsOneWidget); + + // Test item interaction + await tester.tap(find.text('Integration Test Item')); + await tester.pumpAndSettle(); + + // Verify navigation to detail page + expect(find.text('Item Details'), findsOneWidget); + expect(find.text('Integration Test Item'), findsOneWidget); + + // Test back navigation + await tester.tap(find.byIcon(Icons.arrow_back)); + await tester.pumpAndSettle(); + + // Verify back on list page + expect(find.byType(ListView), findsOneWidget); + }); + + testWidgets('error handling integration', (tester) async { + // This test would require network mocking or test environment setup + // to simulate error conditions + + app.main(); + await tester.pumpAndSettle(); + + // Simulate network error scenario + // Implementation depends on your mocking strategy + + // Verify error handling UI + expect(find.text('Something went wrong'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + + // Test retry functionality + await tester.tap(find.text('Retry')); + await tester.pumpAndSettle(); + }); + }); +} +``` + +## Testing Utilities and Helpers + +### Test App Wrapper +```dart +// test/test_app.dart +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:app/l10n/gen_l10n/app_localizations.dart'; + +class TestApp extends StatelessWidget { + final Widget child; + final ThemeData? theme; + + const TestApp({ + super.key, + required this.child, + this.theme, + }); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: theme ?? ThemeData.light(), + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: child, + ); + } +} +``` + +### Mock Data Factories +```dart +// test/factories/example_factory.dart +class ExampleFactory { + static ExampleItem createItem({ + String? id, + String? displayName, + String? formattedValue, + }) { + return ExampleItem( + id: id ?? 'test_id', + displayName: displayName ?? 'Test Item', + formattedValue: formattedValue ?? '\$100.00', + ); + } + + static List createItems(int count) { + return List.generate(count, (index) => + createItem( + id: 'item_$index', + displayName: 'Test Item $index', + formattedValue: '\$${(index + 1) * 100}.00', + ), + ); + } + + static ExampleItemData createItemData({ + String? id, + String? name, + double? value, + }) { + return ExampleItemData( + id: id ?? 'test_id', + name: name ?? 'Test Item', + value: value ?? 100.0, + ); + } +} +``` + +### Custom Matchers +```dart +// test/matchers/custom_matchers.dart +import 'package:flutter_test/flutter_test.dart'; + +Matcher hasA11yFocus() => _HasA11yFocus(); + +class _HasA11yFocus extends Matcher { + @override + bool matches(Object? item, Map matchState) { + // Implementation for checking accessibility focus + return true; // Simplified + } + + @override + Description describe(Description description) { + return description.add('has accessibility focus'); + } +} + +Matcher hasValidationError(String expectedError) => + _HasValidationError(expectedError); + +class _HasValidationError extends Matcher { + final String expectedError; + + _HasValidationError(this.expectedError); + + @override + bool matches(Object? item, Map matchState) { + // Check for validation error in form fields + return true; // Simplified + } + + @override + Description describe(Description description) { + return description.add('has validation error: $expectedError'); + } +} +``` + +## Performance Testing + +### Performance Benchmarks +```dart +// test/performance/widget_performance_test.dart +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Performance Tests', () { + testWidgets('list scrolling performance', (tester) async { + // Create large dataset + final items = ExampleFactory.createItems(1000); + + await tester.pumpWidget( + TestApp( + child: ExampleList(items: items), + ), + ); + + await tester.pumpAndSettle(); + + // Measure scroll performance + final stopwatch = Stopwatch()..start(); + + await tester.fling(find.byType(ListView), Offset(0, -500), 1000); + await tester.pumpAndSettle(); + + stopwatch.stop(); + + // Assert performance criteria + expect(stopwatch.elapsedMilliseconds, lessThan(100)); + }); + + testWidgets('memory usage test', (tester) async { + // Test for memory leaks during widget lifecycle + for (int i = 0; i < 10; i++) { + await tester.pumpWidget(TestApp(child: ExamplePage())); + await tester.pumpAndSettle(); + + await tester.pumpWidget(Container()); // Dispose widget + await tester.pumpAndSettle(); + } + + // Verify no memory leaks (implementation depends on your measurement strategy) + }); + }); +} +``` + +## Test Configuration and Setup + +### Test Configuration Files +```dart +// test/test_config.dart +class TestConfig { + static void setupTests() { + // Global test setup + setUpAll(() { + // Initialize test environment + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + tearDownAll(() { + // Global cleanup + }); + } + + static void setupMockServices() { + // Setup mock services for all tests + GetIt.I.reset(); + GetIt.I.allowReassignment = true; + + // Register mock services + GetIt.I.registerSingleton(MockLogger()); + } +} +``` + +## Testing Checklist + +### Unit Tests +- [ ] **Service layer logic** is thoroughly tested +- [ ] **Error handling** scenarios are covered +- [ ] **Edge cases** are tested (empty data, null values, etc.) +- [ ] **Async operations** are properly tested +- [ ] **Stream behavior** is verified +- [ ] **Dependency injection** is mocked correctly + +### Widget Tests +- [ ] **Widget rendering** is tested for different states +- [ ] **User interactions** (taps, scrolls, text input) are tested +- [ ] **Navigation** behavior is verified +- [ ] **Error states** are properly displayed +- [ ] **Loading states** are shown correctly +- [ ] **Accessibility** features are tested + +### Integration Tests +- [ ] **Complete user workflows** are tested end-to-end +- [ ] **Cross-layer interactions** work correctly +- [ ] **Real API calls** work in test environment (if applicable) +- [ ] **Data persistence** is verified +- [ ] **Performance** meets acceptable criteria + +### Test Quality +- [ ] **Test coverage** meets minimum requirements (aim for 80%+) +- [ ] **Tests are fast** and run quickly in CI/CD +- [ ] **Tests are reliable** and don't flake +- [ ] **Tests are maintainable** and easy to update +- [ ] **Test data** is consistent and realistic +- [ ] **Mocks are accurate** and reflect real behavior + +## Common Testing Anti-Patterns to Avoid + +1. **Testing Implementation Details** - Test behavior, not internal structure +2. **Overly Complex Test Setup** - Keep test setup simple and focused +3. **Brittle Tests** - Don't depend on specific UI element positions or timing +4. **Missing Edge Cases** - Test boundary conditions and error scenarios +5. **Slow Tests** - Optimize for fast feedback loops +6. **Inconsistent Test Data** - Use factories and consistent test data +7. **Missing Integration Tests** - Don't rely only on unit tests + +## CI/CD Integration + +### GitHub Actions Test Configuration +```yaml +# .github/workflows/test.yml +name: Test +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.4.0' + + - name: Install dependencies + run: flutter pub get + working-directory: src/app + + - name: Run unit tests + run: flutter test --coverage + working-directory: src/app + + - name: Run integration tests + run: flutter test integration_test/ + working-directory: src/app + + - name: Upload coverage + uses: codecov/codecov-action@v3 + with: + file: src/app/coverage/lcov.info +``` \ No newline at end of file diff --git a/.github/prompt-templates/ui-component.md b/.github/prompt-templates/ui-component.md new file mode 100644 index 00000000..9141666c --- /dev/null +++ b/.github/prompt-templates/ui-component.md @@ -0,0 +1,1423 @@ +# UI Component Template + +Use this template when creating reusable UI components in the Flutter application. This template ensures components follow the project's design system, are accessible, performant, and testable. + +## Component Context +**Component Name**: [Specify the component name] +**Component Type**: [Basic Widget/Stateful Widget/Composite Widget/Layout Widget] +**Design System**: [Material/Cupertino/Custom] +**Reusability Level**: [Project-wide/Feature-specific/Page-specific] + +## Component Design Principles + +### 1. Follow Project Theme System +```dart +// Always use theme colors and typography +class ThemedButton extends StatelessWidget { + final String text; + final VoidCallback? onPressed; + final ButtonStyle? style; + + const ThemedButton({ + super.key, + required this.text, + this.onPressed, + this.style, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return ElevatedButton( + onPressed: onPressed, + style: style ?? ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + textStyle: theme.textTheme.labelLarge, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text(text), + ); + } +} +``` + +### 2. Implement Responsive Design +```dart +class ResponsiveCard extends StatelessWidget { + final Widget child; + final EdgeInsets? padding; + final double? elevation; + + const ResponsiveCard({ + super.key, + required this.child, + this.padding, + this.elevation, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + // Responsive padding based on screen size + final responsivePadding = padding ?? EdgeInsets.all( + constraints.maxWidth > 600 ? 24.0 : 16.0, + ); + + // Responsive elevation + final responsiveElevation = elevation ?? ( + constraints.maxWidth > 600 ? 4.0 : 2.0 + ); + + return Card( + elevation: responsiveElevation, + margin: EdgeInsets.zero, + child: Padding( + padding: responsivePadding, + child: child, + ), + ); + }, + ); + } +} +``` + +## Component Categories and Examples + +### Basic Input Components + +#### Custom Text Field +```dart +class AppTextField extends StatelessWidget { + final String? label; + final String? hint; + final String? initialValue; + final String? errorText; + final bool obscureText; + final TextInputType keyboardType; + final TextInputAction textInputAction; + final ValueChanged? onChanged; + final VoidCallback? onEditingComplete; + final FormFieldValidator? validator; + final TextEditingController? controller; + final Widget? prefixIcon; + final Widget? suffixIcon; + final bool enabled; + final int? maxLines; + final int? maxLength; + + const AppTextField({ + super.key, + this.label, + this.hint, + this.initialValue, + this.errorText, + this.obscureText = false, + this.keyboardType = TextInputType.text, + this.textInputAction = TextInputAction.done, + this.onChanged, + this.onEditingComplete, + this.validator, + this.controller, + this.prefixIcon, + this.suffixIcon, + this.enabled = true, + this.maxLines = 1, + this.maxLength, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (label != null) ...[ + Text( + label!, + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + ], + TextFormField( + controller: controller, + initialValue: controller == null ? initialValue : null, + obscureText: obscureText, + keyboardType: keyboardType, + textInputAction: textInputAction, + onChanged: onChanged, + onEditingComplete: onEditingComplete, + validator: validator, + enabled: enabled, + maxLines: maxLines, + maxLength: maxLength, + decoration: InputDecoration( + hintText: hint, + errorText: errorText, + prefixIcon: prefixIcon, + suffixIcon: suffixIcon, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: theme.colorScheme.outline), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: theme.colorScheme.outline), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: theme.colorScheme.primary, + width: 2, + ), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: theme.colorScheme.error), + ), + filled: true, + fillColor: enabled + ? theme.colorScheme.surface + : theme.colorScheme.surfaceVariant.withOpacity(0.5), + ), + ), + ], + ); + } +} +``` + +#### Custom Dropdown +```dart +class AppDropdown extends StatelessWidget { + final String? label; + final String? hint; + final T? value; + final List> items; + final ValueChanged? onChanged; + final FormFieldValidator? validator; + final bool enabled; + final String? errorText; + + const AppDropdown({ + super.key, + this.label, + this.hint, + this.value, + required this.items, + this.onChanged, + this.validator, + this.enabled = true, + this.errorText, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (label != null) ...[ + Text( + label!, + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + ], + DropdownButtonFormField( + value: value, + hint: hint != null ? Text(hint!) : null, + items: items.map((item) => DropdownMenuItem( + value: item.value, + child: Row( + children: [ + if (item.icon != null) ...[ + Icon(item.icon, size: 20), + const SizedBox(width: 8), + ], + Expanded(child: Text(item.label)), + ], + ), + )).toList(), + onChanged: enabled ? onChanged : null, + validator: validator, + decoration: InputDecoration( + errorText: errorText, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: theme.colorScheme.outline), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: theme.colorScheme.primary, + width: 2, + ), + ), + filled: true, + fillColor: enabled + ? theme.colorScheme.surface + : theme.colorScheme.surfaceVariant.withOpacity(0.5), + ), + ), + ], + ); + } +} + +class AppDropdownItem { + final T value; + final String label; + final IconData? icon; + + const AppDropdownItem({ + required this.value, + required this.label, + this.icon, + }); +} +``` + +### Display Components + +#### Status Badge +```dart +enum BadgeType { + success, + warning, + error, + info, + neutral, +} + +class StatusBadge extends StatelessWidget { + final String text; + final BadgeType type; + final IconData? icon; + final VoidCallback? onTap; + + const StatusBadge({ + super.key, + required this.text, + required this.type, + this.icon, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = _getBadgeColors(theme); + + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: colors.backgroundColor, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: colors.borderColor), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon( + icon, + size: 16, + color: colors.textColor, + ), + const SizedBox(width: 4), + ], + Text( + text, + style: theme.textTheme.labelSmall?.copyWith( + color: colors.textColor, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } + + BadgeColors _getBadgeColors(ThemeData theme) { + final colorScheme = theme.colorScheme; + + switch (type) { + case BadgeType.success: + return BadgeColors( + backgroundColor: Colors.green.shade50, + borderColor: Colors.green.shade200, + textColor: Colors.green.shade800, + ); + case BadgeType.warning: + return BadgeColors( + backgroundColor: Colors.orange.shade50, + borderColor: Colors.orange.shade200, + textColor: Colors.orange.shade800, + ); + case BadgeType.error: + return BadgeColors( + backgroundColor: colorScheme.errorContainer, + borderColor: colorScheme.error, + textColor: colorScheme.onErrorContainer, + ); + case BadgeType.info: + return BadgeColors( + backgroundColor: Colors.blue.shade50, + borderColor: Colors.blue.shade200, + textColor: Colors.blue.shade800, + ); + case BadgeType.neutral: + return BadgeColors( + backgroundColor: colorScheme.surfaceVariant, + borderColor: colorScheme.outline, + textColor: colorScheme.onSurfaceVariant, + ); + } + } +} + +class BadgeColors { + final Color backgroundColor; + final Color borderColor; + final Color textColor; + + const BadgeColors({ + required this.backgroundColor, + required this.borderColor, + required this.textColor, + }); +} +``` + +#### Loading Indicator +```dart +class AppLoadingIndicator extends StatelessWidget { + final String? message; + final double size; + final Color? color; + final bool overlay; + + const AppLoadingIndicator({ + super.key, + this.message, + this.size = 24.0, + this.color, + this.overlay = false, + }); + + const AppLoadingIndicator.overlay({ + super.key, + this.message, + this.size = 32.0, + this.color, + }) : overlay = true; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final indicator = _buildIndicator(theme); + + if (overlay) { + return Container( + color: Colors.black54, + child: Center( + child: Card( + child: Padding( + padding: const EdgeInsets.all(24), + child: indicator, + ), + ), + ), + ); + } + + return indicator; + } + + Widget _buildIndicator(ThemeData theme) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: size, + height: size, + child: CircularProgressIndicator( + strokeWidth: size > 30 ? 4.0 : 2.0, + valueColor: AlwaysStoppedAnimation( + color ?? theme.colorScheme.primary, + ), + ), + ), + if (message != null) ...[ + const SizedBox(height: 16), + Text( + message!, + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ], + ); + } +} +``` + +### Feedback Components + +#### Error Widget +```dart +class ErrorDisplayWidget extends StatelessWidget { + final String title; + final String? message; + final IconData? icon; + final String? actionText; + final VoidCallback? onAction; + final bool showRetry; + + const ErrorDisplayWidget({ + super.key, + required this.title, + this.message, + this.icon, + this.actionText, + this.onAction, + this.showRetry = true, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final localizations = context.local; + + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon ?? Icons.error_outline, + size: 64, + color: theme.colorScheme.error, + ), + const SizedBox(height: 16), + Text( + title, + style: theme.textTheme.headlineSmall?.copyWith( + color: theme.colorScheme.onSurface, + ), + textAlign: TextAlign.center, + ), + if (message != null) ...[ + const SizedBox(height: 8), + Text( + message!, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 24), + if (showRetry) ...[ + ThemedButton( + text: actionText ?? localizations.retry, + onPressed: onAction, + ), + ] else if (onAction != null) ...[ + ThemedButton( + text: actionText ?? localizations.ok, + onPressed: onAction, + ), + ], + ], + ), + ), + ); + } +} +``` + +#### Empty State Widget +```dart +class EmptyStateWidget extends StatelessWidget { + final String title; + final String? message; + final IconData? icon; + final Widget? illustration; + final String? actionText; + final VoidCallback? onAction; + + const EmptyStateWidget({ + super.key, + required this.title, + this.message, + this.icon, + this.illustration, + this.actionText, + this.onAction, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (illustration != null) ...[ + illustration!, + const SizedBox(height: 16), + ] else if (icon != null) ...[ + Icon( + icon, + size: 64, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + ], + Text( + title, + style: theme.textTheme.headlineSmall?.copyWith( + color: theme.colorScheme.onSurface, + ), + textAlign: TextAlign.center, + ), + if (message != null) ...[ + const SizedBox(height: 8), + Text( + message!, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + if (onAction != null) ...[ + const SizedBox(height: 24), + ThemedButton( + text: actionText ?? 'Get Started', + onPressed: onAction, + ), + ], + ], + ), + ), + ); + } +} +``` + +### Layout Components + +#### Section Header +```dart +class SectionHeader extends StatelessWidget { + final String title; + final String? subtitle; + final Widget? action; + final EdgeInsets padding; + + const SectionHeader({ + super.key, + required this.title, + this.subtitle, + this.action, + this.padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Padding( + padding: padding, + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 4), + Text( + subtitle!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + if (action != null) ...[ + const SizedBox(width: 16), + action!, + ], + ], + ), + ); + } +} +``` + +#### Expandable Section +```dart +class ExpandableSection extends StatefulWidget { + final String title; + final Widget child; + final bool initiallyExpanded; + final IconData? leadingIcon; + final Widget? trailing; + + const ExpandableSection({ + super.key, + required this.title, + required this.child, + this.initiallyExpanded = false, + this.leadingIcon, + this.trailing, + }); + + @override + State createState() => _ExpandableSectionState(); +} + +class _ExpandableSectionState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _expandAnimation; + bool _isExpanded = false; + + @override + void initState() { + super.initState(); + _isExpanded = widget.initiallyExpanded; + _controller = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + _expandAnimation = CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + ); + + if (_isExpanded) { + _controller.value = 1.0; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _toggleExpansion() { + setState(() { + _isExpanded = !_isExpanded; + if (_isExpanded) { + _controller.forward(); + } else { + _controller.reverse(); + } + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + children: [ + InkWell( + onTap: _toggleExpansion, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + if (widget.leadingIcon != null) ...[ + Icon(widget.leadingIcon, size: 20), + const SizedBox(width: 12), + ], + Expanded( + child: Text( + widget.title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + if (widget.trailing != null) ...[ + widget.trailing!, + const SizedBox(width: 8), + ], + AnimatedRotation( + turns: _isExpanded ? 0.5 : 0, + duration: const Duration(milliseconds: 300), + child: const Icon(Icons.keyboard_arrow_down), + ), + ], + ), + ), + ), + SizeTransition( + sizeFactor: _expandAnimation, + child: widget.child, + ), + ], + ); + } +} +``` + +### Navigation Components + +#### Tab Bar +```dart +class AppTabBar extends StatelessWidget implements PreferredSizeWidget { + final List tabs; + final TabController? controller; + final ValueChanged? onTap; + final bool isScrollable; + + const AppTabBar({ + super.key, + required this.tabs, + this.controller, + this.onTap, + this.isScrollable = false, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return TabBar( + controller: controller, + onTap: onTap, + isScrollable: isScrollable, + tabs: tabs.map((tab) => Tab( + icon: tab.icon != null ? Icon(tab.icon) : null, + text: tab.text, + child: tab.child, + )).toList(), + labelColor: theme.colorScheme.primary, + unselectedLabelColor: theme.colorScheme.onSurfaceVariant, + indicatorColor: theme.colorScheme.primary, + indicatorWeight: 3, + ); + } + + @override + Size get preferredSize => const Size.fromHeight(48); +} + +class AppTab { + final String? text; + final IconData? icon; + final Widget? child; + + const AppTab({ + this.text, + this.icon, + this.child, + }) : assert(text != null || icon != null || child != null); +} +``` + +#### Bottom Navigation +```dart +class AppBottomNavigation extends StatelessWidget { + final int currentIndex; + final ValueChanged onTap; + final List items; + + const AppBottomNavigation({ + super.key, + required this.currentIndex, + required this.onTap, + required this.items, + }); + + @override + Widget build(BuildContext context) { + return BottomNavigationBar( + currentIndex: currentIndex, + onTap: onTap, + type: BottomNavigationBarType.fixed, + items: items.map((item) => BottomNavigationBarItem( + icon: Icon(item.icon), + activeIcon: item.activeIcon != null + ? Icon(item.activeIcon) + : Icon(item.icon), + label: item.label, + tooltip: item.tooltip, + )).toList(), + ); + } +} + +class AppBottomNavItem { + final IconData icon; + final IconData? activeIcon; + final String label; + final String? tooltip; + + const AppBottomNavItem({ + required this.icon, + this.activeIcon, + required this.label, + this.tooltip, + }); +} +``` + +## Accessibility Implementation + +### Screen Reader Support +```dart +class AccessibleCard extends StatelessWidget { + final Widget child; + final String? semanticsLabel; + final String? semanticsHint; + final VoidCallback? onTap; + final bool excludeSemantics; + + const AccessibleCard({ + super.key, + required this.child, + this.semanticsLabel, + this.semanticsHint, + this.onTap, + this.excludeSemantics = false, + }); + + @override + Widget build(BuildContext context) { + Widget card = Card( + child: InkWell( + onTap: onTap, + child: child, + ), + ); + + if (excludeSemantics) { + return ExcludeSemantics(child: card); + } + + return Semantics( + label: semanticsLabel, + hint: semanticsHint, + button: onTap != null, + child: card, + ); + } +} +``` + +### Focus Management +```dart +class FocusableMenuItem extends StatefulWidget { + final String text; + final IconData? icon; + final VoidCallback? onTap; + final bool enabled; + + const FocusableMenuItem({ + super.key, + required this.text, + this.icon, + this.onTap, + this.enabled = true, + }); + + @override + State createState() => _FocusableMenuItemState(); +} + +class _FocusableMenuItemState extends State { + late final FocusNode _focusNode; + bool _isFocused = false; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(); + _focusNode.addListener(_onFocusChange); + } + + @override + void dispose() { + _focusNode.removeListener(_onFocusChange); + _focusNode.dispose(); + super.dispose(); + } + + void _onFocusChange() { + setState(() { + _isFocused = _focusNode.hasFocus; + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Focus( + focusNode: _focusNode, + child: GestureDetector( + onTap: widget.enabled ? widget.onTap : null, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _isFocused + ? theme.colorScheme.surfaceVariant + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: _isFocused + ? Border.all(color: theme.colorScheme.primary, width: 2) + : null, + ), + child: Row( + children: [ + if (widget.icon != null) ...[ + Icon( + widget.icon, + color: widget.enabled + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurface.withOpacity(0.4), + ), + const SizedBox(width: 12), + ], + Expanded( + child: Text( + widget.text, + style: theme.textTheme.bodyMedium?.copyWith( + color: widget.enabled + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurface.withOpacity(0.4), + ), + ), + ), + ], + ), + ), + ), + ); + } +} +``` + +## Performance Optimization + +### Efficient List Item +```dart +class OptimizedListItem extends StatelessWidget { + final String title; + final String? subtitle; + final Widget? leading; + final Widget? trailing; + final VoidCallback? onTap; + + const OptimizedListItem({ + super.key, + required this.title, + this.subtitle, + this.leading, + this.trailing, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + // Use const where possible for better performance + return ListTile( + leading: leading, + title: Text(title), + subtitle: subtitle != null ? Text(subtitle!) : null, + trailing: trailing, + onTap: onTap, + // Enable efficient scrolling + dense: true, + ); + } +} +``` + +### Cached Network Image +```dart +class AppNetworkImage extends StatelessWidget { + final String imageUrl; + final double? width; + final double? height; + final BoxFit fit; + final Widget? placeholder; + final Widget? errorWidget; + + const AppNetworkImage({ + super.key, + required this.imageUrl, + this.width, + this.height, + this.fit = BoxFit.cover, + this.placeholder, + this.errorWidget, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Image.network( + imageUrl, + width: width, + height: height, + fit: fit, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + + return placeholder ?? Container( + width: width, + height: height, + color: theme.colorScheme.surfaceVariant, + child: const Center( + child: CircularProgressIndicator(), + ), + ); + }, + errorBuilder: (context, error, stackTrace) { + return errorWidget ?? Container( + width: width, + height: height, + color: theme.colorScheme.errorContainer, + child: Icon( + Icons.error_outline, + color: theme.colorScheme.onErrorContainer, + ), + ); + }, + // Enable caching + cacheWidth: width?.toInt(), + cacheHeight: height?.toInt(), + ); + } +} +``` + +## Component Testing + +### Widget Tests +```dart +// test/presentation/components/themed_button_test.dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; + +void main() { + group('ThemedButton', () { + testWidgets('should display text correctly', (tester) async { + // Arrange + const buttonText = 'Test Button'; + bool tapped = false; + + // Act + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ThemedButton( + text: buttonText, + onPressed: () => tapped = true, + ), + ), + ), + ); + + // Assert + expect(find.text(buttonText), findsOneWidget); + expect(find.byType(ElevatedButton), findsOneWidget); + + // Test interaction + await tester.tap(find.byType(ThemedButton)); + expect(tapped, isTrue); + }); + + testWidgets('should be disabled when onPressed is null', (tester) async { + // Arrange & Act + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ThemedButton( + text: 'Disabled Button', + onPressed: null, // Disabled + ), + ), + ), + ); + + // Assert + final button = tester.widget(find.byType(ElevatedButton)); + expect(button.onPressed, isNull); + }); + + testWidgets('should apply custom style', (tester) async { + // Arrange + final customStyle = ElevatedButton.styleFrom( + backgroundColor: Colors.red, + ); + + // Act + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ThemedButton( + text: 'Custom Button', + style: customStyle, + onPressed: () {}, + ), + ), + ), + ); + + // Assert + final button = tester.widget(find.byType(ElevatedButton)); + expect(button.style, equals(customStyle)); + }); + + testWidgets('should respect theme colors', (tester) async { + // Arrange + final customTheme = ThemeData( + colorScheme: const ColorScheme.light( + primary: Colors.purple, + onPrimary: Colors.white, + ), + ); + + // Act + await tester.pumpWidget( + MaterialApp( + theme: customTheme, + home: Scaffold( + body: ThemedButton( + text: 'Themed Button', + onPressed: () {}, + ), + ), + ), + ); + + // Assert - Button should use theme colors + expect(find.byType(ThemedButton), findsOneWidget); + }); + }); +} +``` + +### Component Integration Tests +```dart +// test/presentation/components/app_text_field_test.dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; + +void main() { + group('AppTextField', () { + testWidgets('should validate input correctly', (tester) async { + // Arrange + String? validationError; + + String? validator(String? value) { + if (value == null || value.isEmpty) { + return 'Field is required'; + } + return null; + } + + // Act + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Form( + child: AppTextField( + label: 'Test Field', + validator: validator, + ), + ), + ), + ), + ); + + // Trigger validation + final formState = tester.state(find.byType(Form)); + final isValid = formState.validate(); + + // Assert + expect(isValid, isFalse); + expect(find.text('Field is required'), findsOneWidget); + }); + + testWidgets('should handle text input', (tester) async { + // Arrange + String inputValue = ''; + + // Act + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AppTextField( + label: 'Test Field', + onChanged: (value) => inputValue = value, + ), + ), + ), + ); + + await tester.enterText(find.byType(TextFormField), 'Test Input'); + await tester.pump(); + + // Assert + expect(inputValue, equals('Test Input')); + expect(find.text('Test Input'), findsOneWidget); + }); + }); +} +``` + +## Component Documentation + +### Component Usage Examples +```dart +/// Usage examples for ThemedButton component +/// +/// Basic usage: +/// ```dart +/// ThemedButton( +/// text: 'Click me', +/// onPressed: () => print('Button pressed'), +/// ) +/// ``` +/// +/// With custom style: +/// ```dart +/// ThemedButton( +/// text: 'Custom Button', +/// style: ElevatedButton.styleFrom(backgroundColor: Colors.red), +/// onPressed: () => handleCustomAction(), +/// ) +/// ``` +/// +/// Disabled state: +/// ```dart +/// ThemedButton( +/// text: 'Disabled', +/// onPressed: null, // Disabled when null +/// ) +/// ``` +class ThemedButton extends StatelessWidget { + // Implementation... +} +``` + +## Component Checklist + +### Design & UX +- [ ] **Follows design system** colors, typography, and spacing +- [ ] **Responsive design** works on different screen sizes +- [ ] **Loading states** are handled appropriately +- [ ] **Error states** are clearly communicated +- [ ] **Empty states** provide helpful guidance +- [ ] **Animations** are smooth and purposeful + +### Accessibility +- [ ] **Screen reader support** with proper semantics +- [ ] **Keyboard navigation** works correctly +- [ ] **Focus management** is implemented +- [ ] **Color contrast** meets WCAG guidelines +- [ ] **Touch targets** are at least 44px +- [ ] **Text scaling** works with system settings + +### Performance +- [ ] **Const constructors** used where possible +- [ ] **Widget rebuilds** are minimized +- [ ] **Memory usage** is optimized +- [ ] **Large lists** use builders +- [ ] **Images** are optimized and cached +- [ ] **Animations** are performant + +### Testing +- [ ] **Unit tests** cover business logic +- [ ] **Widget tests** verify UI behavior +- [ ] **Integration tests** check component interactions +- [ ] **Edge cases** are tested +- [ ] **Error scenarios** are covered +- [ ] **Accessibility testing** is included + +### Code Quality +- [ ] **Documentation** is comprehensive +- [ ] **API surface** is minimal and focused +- [ ] **Error handling** is robust +- [ ] **Null safety** is properly implemented +- [ ] **Type safety** is maintained +- [ ] **Code style** follows project conventions + +## Common Component Anti-Patterns + +1. **God Components** - Keep components focused and single-purpose +2. **Prop Drilling** - Use state management for deep data passing +3. **Tight Coupling** - Make components reusable and independent +4. **Missing Error Handling** - Always handle edge cases gracefully +5. **Poor Performance** - Optimize for smooth scrolling and responsiveness +6. **Inaccessible Components** - Always consider users with disabilities +7. **Inconsistent Styling** - Follow the established design system + +## Component Library Organization + +``` +lib/presentation/components/ +├── buttons/ +│ ├── themed_button.dart +│ └── icon_button.dart +├── inputs/ +│ ├── app_text_field.dart +│ └── app_dropdown.dart +├── display/ +│ ├── status_badge.dart +│ └── loading_indicator.dart +├── feedback/ +│ ├── error_widget.dart +│ └── empty_state.dart +├── layout/ +│ ├── section_header.dart +│ └── expandable_section.dart +└── navigation/ + ├── app_tab_bar.dart + └── bottom_navigation.dart +``` + +This organization helps maintain consistency and makes components easy to find and use across the application. \ No newline at end of file