diff --git a/lib/config/environment_config.dart b/lib/config/environment_config.dart new file mode 100644 index 0000000..6863151 --- /dev/null +++ b/lib/config/environment_config.dart @@ -0,0 +1,67 @@ +/// Environment Configuration for Coves Mobile +/// +/// Supports multiple environments: +/// - Production: Real Bluesky infrastructure +/// - Local: Local PDS + PLC for development/testing +/// +/// Set via ENVIRONMENT environment variable or flutter run --dart-define +enum Environment { + production, + local, +} + +class EnvironmentConfig { + + const EnvironmentConfig({ + required this.environment, + required this.apiUrl, + required this.handleResolverUrl, + required this.plcDirectoryUrl, + }); + final Environment environment; + final String apiUrl; + final String handleResolverUrl; + final String plcDirectoryUrl; + + /// Production configuration (default) + /// Uses real Bluesky infrastructure + static const production = EnvironmentConfig( + environment: Environment.production, + apiUrl: 'https://coves.social', // TODO: Update when production is live + handleResolverUrl: 'https://bsky.social/xrpc/com.atproto.identity.resolveHandle', + plcDirectoryUrl: 'https://plc.directory', + ); + + /// Local development configuration + /// Uses localhost via adb reverse port forwarding + /// + /// IMPORTANT: Before testing, run these commands to forward ports: + /// adb reverse tcp:3001 tcp:3001 # PDS + /// adb reverse tcp:3002 tcp:3002 # PLC + /// adb reverse tcp:8081 tcp:8081 # AppView + /// + /// Note: For physical devices not connected via USB, use ngrok URLs instead + static const local = EnvironmentConfig( + environment: Environment.local, + apiUrl: 'http://localhost:8081', + handleResolverUrl: 'http://localhost:3001/xrpc/com.atproto.identity.resolveHandle', + plcDirectoryUrl: 'http://localhost:3002', + ); + + /// Get current environment based on build configuration + static EnvironmentConfig get current { + // Read from --dart-define=ENVIRONMENT=local + const envString = String.fromEnvironment('ENVIRONMENT', defaultValue: 'production'); + + switch (envString) { + case 'local': + return local; + case 'production': + default: + return production; + } + } + + bool get isProduction => environment == Environment.production; + bool get isLocal => environment == Environment.local; +} diff --git a/lib/config/oauth_config.dart b/lib/config/oauth_config.dart index 8bb2b5a..d25d86b 100644 --- a/lib/config/oauth_config.dart +++ b/lib/config/oauth_config.dart @@ -1,5 +1,7 @@ import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart'; +import 'environment_config.dart'; + /// OAuth Configuration for atProto /// /// This configuration provides ClientMetadata for the new @@ -19,9 +21,8 @@ class OAuthConfig { 'dev.workers.brettmay0212.lingering-darkness-50a6'; // API Configuration - // Using adb reverse port forwarding, phone can access via localhost - // Setup: adb reverse tcp:8081 tcp:8081 - static const String apiUrl = 'http://localhost:8081'; + // Environment-aware API URL + static String get apiUrl => EnvironmentConfig.current.apiUrl; // Derived OAuth URLs static const String clientId = '$oauthServerUrl/client-metadata.json'; diff --git a/lib/providers/feed_provider.dart b/lib/providers/feed_provider.dart index 215dc96..10ea911 100644 --- a/lib/providers/feed_provider.dart +++ b/lib/providers/feed_provider.dart @@ -21,6 +21,9 @@ class FeedProvider with ChangeNotifier { apiService ?? CovesApiService(tokenGetter: _authProvider.getAccessToken); + // Track initial auth state + _wasAuthenticated = _authProvider.isAuthenticated; + // [P0 FIX] Listen to auth state changes and clear feed on sign-out // This prevents privacy bug where logged-out users see their private // timeline until they manually refresh. @@ -29,25 +32,32 @@ class FeedProvider with ChangeNotifier { /// Handle authentication state changes /// - /// When the user signs out (isAuthenticated becomes false), - /// immediately clear the feed to prevent showing personalized content - /// to logged-out users. This fixes a privacy bug where token refresh - /// failures would sign out the user but leave their private timeline - /// visible until manual refresh. + /// Only clears and reloads feed when transitioning from authenticated + /// to unauthenticated (actual sign-out), not when staying unauthenticated + /// (e.g., failed sign-in attempt). This prevents unnecessary API calls. void _onAuthChanged() { - if (!_authProvider.isAuthenticated && _posts.isNotEmpty) { + final isAuthenticated = _authProvider.isAuthenticated; + + // Only reload if transitioning from authenticated → unauthenticated + if (_wasAuthenticated && !isAuthenticated && _posts.isNotEmpty) { if (kDebugMode) { - debugPrint('🔒 Auth state changed to unauthenticated - clearing feed'); + debugPrint('🔒 User signed out - clearing feed'); } reset(); // Automatically load the public discover feed loadFeed(refresh: true); } + + // Update tracked state + _wasAuthenticated = isAuthenticated; } final AuthProvider _authProvider; late final CovesApiService _apiService; + // Track previous auth state to detect transitions + bool _wasAuthenticated = false; + // Feed state List _posts = []; bool _isLoading = false; diff --git a/lib/services/api_exceptions.dart b/lib/services/api_exceptions.dart index d05e87f..59e492d 100644 --- a/lib/services/api_exceptions.dart +++ b/lib/services/api_exceptions.dart @@ -4,9 +4,58 @@ /// This allows better error handling and user-friendly error messages. library; +import 'package:dio/dio.dart'; + /// Base class for all API exceptions class ApiException implements Exception { ApiException(this.message, {this.statusCode, this.originalError}); + + /// Create ApiException from DioException + factory ApiException.fromDioError(DioException error) { + switch (error.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + return NetworkException( + 'Request timeout. Please check your connection.', + originalError: error, + ); + case DioExceptionType.badResponse: + final statusCode = error.response?.statusCode; + final message = + error.response?.data?['message'] as String? ?? + error.response?.data?['error'] as String? ?? + 'Server error'; + + if (statusCode == 401) { + return AuthenticationException(message, originalError: error); + } else if (statusCode == 404) { + return NotFoundException(message, originalError: error); + } else if (statusCode != null && statusCode >= 500) { + return ServerException( + message, + statusCode: statusCode, + originalError: error, + ); + } + return ApiException( + message, + statusCode: statusCode, + originalError: error, + ); + case DioExceptionType.cancel: + return ApiException('Request was cancelled', originalError: error); + case DioExceptionType.connectionError: + return NetworkException( + 'Connection failed. Please check your internet.', + originalError: error, + ); + case DioExceptionType.badCertificate: + return NetworkException('SSL certificate error', originalError: error); + case DioExceptionType.unknown: + return NetworkException('Network error occurred', originalError: error); + } + } final String message; final int? statusCode; final dynamic originalError; diff --git a/lib/services/oauth_service.dart b/lib/services/oauth_service.dart index a321777..48a59c1 100644 --- a/lib/services/oauth_service.dart +++ b/lib/services/oauth_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart'; import 'package:flutter/foundation.dart'; +import '../config/environment_config.dart'; import '../config/oauth_config.dart'; /// OAuth Service for atProto authentication using the new @@ -43,9 +44,16 @@ class OAuthService { /// - Automatic session management Future initialize() async { try { + // Get environment configuration + final config = EnvironmentConfig.current; + // Create client with metadata from config + // For local development, use custom resolvers _client = FlutterOAuthClient( clientMetadata: OAuthConfig.createClientMetadata(), + plcDirectoryUrl: config.plcDirectoryUrl, + handleResolverUrl: config.handleResolverUrl, + allowHttp: config.isLocal, // Allow HTTP for local development ); // Set up session event listeners @@ -53,9 +61,13 @@ class OAuthService { if (kDebugMode) { print('✅ FlutterOAuthClient initialized'); + print(' Environment: ${config.environment}'); print(' Client ID: ${OAuthConfig.clientId}'); print(' Redirect URI: ${OAuthConfig.customSchemeCallback}'); print(' Scope: ${OAuthConfig.scope}'); + print(' Handle Resolver: ${config.handleResolverUrl}'); + print(' PLC Directory: ${config.plcDirectoryUrl}'); + print(' Allow HTTP: ${config.isLocal}'); } } catch (e) { if (kDebugMode) { diff --git a/lib/services/pds_discovery_service.dart b/lib/services/pds_discovery_service.dart index ca47409..2b68e57 100644 --- a/lib/services/pds_discovery_service.dart +++ b/lib/services/pds_discovery_service.dart @@ -1,5 +1,7 @@ import 'package:dio/dio.dart'; +import '../config/environment_config.dart'; + /// PDS Discovery Service /// /// Handles the resolution of atProto handles to their Personal Data @@ -8,12 +10,16 @@ import 'package:dio/dio.dart'; /// redirect them to THEIR PDS's OAuth server. /// /// Flow: -/// 1. Resolve handle to DID using a handle resolver (bsky.social) +/// 1. Resolve handle to DID using a handle resolver /// 2. Fetch the DID document from the PLC directory /// 3. Extract the PDS endpoint from the service array /// 4. Return the PDS URL for OAuth discovery class PDSDiscoveryService { + PDSDiscoveryService({EnvironmentConfig? config}) + : _config = config ?? EnvironmentConfig.current; + final Dio _dio = Dio(); + final EnvironmentConfig _config; /// Discover the PDS URL for a given atProto handle /// @@ -41,12 +47,11 @@ class PDSDiscoveryService { /// Resolve an atProto handle to a DID /// - /// Uses Bluesky's public resolver which can resolve ANY atProto handle, - /// not just bsky.social handles. + /// Uses configured handle resolver (production: Bluesky, local: your PDS) Future _resolveHandle(String handle) async { try { final response = await _dio.get( - 'https://bsky.social/xrpc/com.atproto.identity.resolveHandle', + _config.handleResolverUrl, queryParameters: {'handle': handle}, ); @@ -68,7 +73,7 @@ class PDSDiscoveryService { /// Fetch a DID document from the PLC directory Future> _fetchDIDDocument(String did) async { try { - final response = await _dio.get('https://plc.directory/$did'); + final response = await _dio.get('${_config.plcDirectoryUrl}/$did'); if (response.statusCode != 200) { throw Exception('Failed to fetch DID document: ${response.statusCode}'); diff --git a/lib/widgets/icons/animated_heart_icon.dart b/lib/widgets/icons/animated_heart_icon.dart index 05eed91..2f271a9 100644 --- a/lib/widgets/icons/animated_heart_icon.dart +++ b/lib/widgets/icons/animated_heart_icon.dart @@ -121,7 +121,7 @@ class _AnimatedHeartIconState extends State _hasBeenToggled = true; _previousIsLiked = widget.isLiked; - if (widget.isLiked) { + if (widget.isLiked && mounted) { _controller.forward(from: 0); } } @@ -134,7 +134,9 @@ class _AnimatedHeartIconState extends State } double _getHeartScale() { - if (!widget.isLiked || !_hasBeenToggled) return 1; + if (!widget.isLiked || !_hasBeenToggled) { + return 1; + } final progress = _controller.value; if (progress < 0.15) { @@ -233,7 +235,7 @@ class _AnimatedHeartIconState extends State final particles = []; final containerSize = widget.size * 2.5; - for (int i = 0; i < particleCount; i++) { + for (var i = 0; i < particleCount; i++) { final angle = (2 * math.pi * i) / particleCount; final distance = widget.size * 1 * _particleScaleAnimation.value; final dx = math.cos(angle) * distance; @@ -283,36 +285,37 @@ class _HeartIconPainter extends CustomPainter { if (filled) { // Filled heart path from Bluesky - path.moveTo(12.489, 21.372); - path.cubicTo(21.017, 16.592, 23.115, 10.902, 21.511, 6.902); - path.cubicTo(20.732, 4.961, 19.097, 3.569, 17.169, 3.139); - path.cubicTo(15.472, 2.761, 13.617, 3.142, 12, 4.426); - path.cubicTo(10.383, 3.142, 8.528, 2.761, 6.83, 3.139); - path.cubicTo(4.903, 3.569, 3.268, 4.961, 2.49, 6.903); - path.cubicTo(0.885, 10.903, 2.983, 16.593, 11.511, 21.373); - path.cubicTo(11.826, 21.558, 12.174, 21.558, 12.489, 21.372); - path.close(); + path + ..moveTo(12.489, 21.372) + ..cubicTo(21.017, 16.592, 23.115, 10.902, 21.511, 6.902) + ..cubicTo(20.732, 4.961, 19.097, 3.569, 17.169, 3.139) + ..cubicTo(15.472, 2.761, 13.617, 3.142, 12, 4.426) + ..cubicTo(10.383, 3.142, 8.528, 2.761, 6.83, 3.139) + ..cubicTo(4.903, 3.569, 3.268, 4.961, 2.49, 6.903) + ..cubicTo(0.885, 10.903, 2.983, 16.593, 11.511, 21.373) + ..cubicTo(11.826, 21.558, 12.174, 21.558, 12.489, 21.372) + ..close(); } else { // Outline heart path from Bluesky - path.moveTo(16.734, 5.091); - path.cubicTo(15.496, 4.815, 14.026, 5.138, 12.712, 6.471); - path.cubicTo(12.318, 6.865, 11.682, 6.865, 11.288, 6.471); - path.cubicTo(9.974, 5.137, 8.504, 4.814, 7.266, 5.09); - path.cubicTo(6.003, 5.372, 4.887, 6.296, 4.346, 7.646); - path.cubicTo(3.33, 10.18, 4.252, 14.84, 12, 19.348); - path.cubicTo(19.747, 14.84, 20.67, 10.18, 19.654, 7.648); - path.cubicTo(19.113, 6.297, 17.997, 5.373, 16.734, 5.091); - path.close(); - - path.moveTo(21.511, 6.903); - path.cubicTo(23.115, 10.903, 21.017, 16.593, 12.489, 21.373); - path.cubicTo(12.174, 21.558, 11.826, 21.558, 11.511, 21.373); - path.cubicTo(2.983, 16.592, 0.885, 10.902, 2.49, 6.902); - path.cubicTo(3.269, 4.96, 4.904, 3.568, 6.832, 3.138); - path.cubicTo(8.529, 2.76, 10.384, 3.141, 12.001, 4.424); - path.cubicTo(13.618, 3.141, 15.473, 2.76, 17.171, 3.138); - path.cubicTo(19.098, 3.568, 20.733, 4.96, 21.511, 6.903); - path.close(); + path + ..moveTo(16.734, 5.091) + ..cubicTo(15.496, 4.815, 14.026, 5.138, 12.712, 6.471) + ..cubicTo(12.318, 6.865, 11.682, 6.865, 11.288, 6.471) + ..cubicTo(9.974, 5.137, 8.504, 4.814, 7.266, 5.09) + ..cubicTo(6.003, 5.372, 4.887, 6.296, 4.346, 7.646) + ..cubicTo(3.33, 10.18, 4.252, 14.84, 12, 19.348) + ..cubicTo(19.747, 14.84, 20.67, 10.18, 19.654, 7.648) + ..cubicTo(19.113, 6.297, 17.997, 5.373, 16.734, 5.091) + ..close() + ..moveTo(21.511, 6.903) + ..cubicTo(23.115, 10.903, 21.017, 16.593, 12.489, 21.373) + ..cubicTo(12.174, 21.558, 11.826, 21.558, 11.511, 21.373) + ..cubicTo(2.983, 16.592, 0.885, 10.902, 2.49, 6.902) + ..cubicTo(3.269, 4.96, 4.904, 3.568, 6.832, 3.138) + ..cubicTo(8.529, 2.76, 10.384, 3.141, 12.001, 4.424) + ..cubicTo(13.618, 3.141, 15.473, 2.76, 17.171, 3.138) + ..cubicTo(19.098, 3.568, 20.733, 4.96, 21.511, 6.903) + ..close(); } canvas.drawPath(path, paint); diff --git a/lib/widgets/icons/reply_icon.dart b/lib/widgets/icons/reply_icon.dart index 9813dd9..1574a65 100644 --- a/lib/widgets/icons/reply_icon.dart +++ b/lib/widgets/icons/reply_icon.dart @@ -48,61 +48,63 @@ class _ReplyIconPainter extends CustomPainter { // Filled reply icon path from Bluesky // M22.002 15a4 4 0 0 1-4 4h-4.648l-4.727 3.781A1.001 1.001 0 0 1 7.002 22 // v-3h-1a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z - path.moveTo(22.002, 15); - path.cubicTo(22.002, 17.209, 20.211, 19, 18.002, 19); - path.lineTo(13.354, 19); - path.lineTo(8.627, 22.781); - path.cubicTo(8.243, 23.074, 7.683, 22.808, 7.627, 22.318); - path.lineTo(7.002, 22); - path.lineTo(7.002, 19); - path.lineTo(6.002, 19); - path.cubicTo(3.793, 19, 2.002, 17.209, 2.002, 15); - path.lineTo(2.002, 7); - path.cubicTo(2.002, 4.791, 3.793, 3, 6.002, 3); - path.lineTo(18.002, 3); - path.cubicTo(20.211, 3, 22.002, 4.791, 22.002, 7); - path.lineTo(22.002, 15); - path.close(); + path + ..moveTo(22.002, 15) + ..cubicTo(22.002, 17.209, 20.211, 19, 18.002, 19) + ..lineTo(13.354, 19) + ..lineTo(8.627, 22.781) + ..cubicTo(8.243, 23.074, 7.683, 22.808, 7.627, 22.318) + ..lineTo(7.002, 22) + ..lineTo(7.002, 19) + ..lineTo(6.002, 19) + ..cubicTo(3.793, 19, 2.002, 17.209, 2.002, 15) + ..lineTo(2.002, 7) + ..cubicTo(2.002, 4.791, 3.793, 3, 6.002, 3) + ..lineTo(18.002, 3) + ..cubicTo(20.211, 3, 22.002, 4.791, 22.002, 7) + ..lineTo(22.002, 15) + ..close(); } else { // Outline reply icon path from Bluesky - // M20.002 7a2 2 0 0 0-2-2h-12a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2a1 1 0 0 1 1 1 - // v1.918l3.375-2.7a1 1 0 0 1 .625-.218h5a2 2 0 0 0 2-2V7Zm2 8a4 4 0 0 1-4 4 - // h-4.648l-4.727 3.781A1.001 1.001 0 0 1 7.002 22v-3h-1a4 4 0 0 1-4-4V7 + // M20.002 7a2 2 0 0 0-2-2h-12a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2 + // a1 1 0 0 1 1 1 v1.918l3.375-2.7a1 1 0 0 1 .625-.218h5 + // a2 2 0 0 0 2-2V7Zm2 8a4 4 0 0 1-4 4 h-4.648l-4.727 3.781 + // A1.001 1.001 0 0 1 7.002 22v-3h-1a4 4 0 0 1-4-4V7 // a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z // Inner shape - path.moveTo(20.002, 7); - path.cubicTo(20.002, 5.895, 19.107, 5, 18.002, 5); - path.lineTo(6.002, 5); - path.cubicTo(4.897, 5, 4.002, 5.895, 4.002, 7); - path.lineTo(4.002, 15); - path.cubicTo(4.002, 16.105, 4.897, 17, 6.002, 17); - path.lineTo(8.002, 17); - path.cubicTo(8.554, 17, 9.002, 17.448, 9.002, 18); - path.lineTo(9.002, 19.918); - path.lineTo(12.377, 17.218); - path.cubicTo(12.574, 17.073, 12.813, 17, 13.002, 17); - path.lineTo(18.002, 17); - path.cubicTo(19.107, 17, 20.002, 16.105, 20.002, 15); - path.lineTo(20.002, 7); - path.close(); - - // Outer shape - path.moveTo(22.002, 15); - path.cubicTo(22.002, 17.209, 20.211, 19, 18.002, 19); - path.lineTo(13.354, 19); - path.lineTo(8.627, 22.781); - path.cubicTo(8.243, 23.074, 7.683, 22.808, 7.627, 22.318); - path.lineTo(7.002, 22); - path.lineTo(7.002, 19); - path.lineTo(6.002, 19); - path.cubicTo(3.793, 19, 2.002, 17.209, 2.002, 15); - path.lineTo(2.002, 7); - path.cubicTo(2.002, 4.791, 3.793, 3, 6.002, 3); - path.lineTo(18.002, 3); - path.cubicTo(20.211, 3, 22.002, 4.791, 22.002, 7); - path.lineTo(22.002, 15); - path.close(); + path + ..moveTo(20.002, 7) + ..cubicTo(20.002, 5.895, 19.107, 5, 18.002, 5) + ..lineTo(6.002, 5) + ..cubicTo(4.897, 5, 4.002, 5.895, 4.002, 7) + ..lineTo(4.002, 15) + ..cubicTo(4.002, 16.105, 4.897, 17, 6.002, 17) + ..lineTo(8.002, 17) + ..cubicTo(8.554, 17, 9.002, 17.448, 9.002, 18) + ..lineTo(9.002, 19.918) + ..lineTo(12.377, 17.218) + ..cubicTo(12.574, 17.073, 12.813, 17, 13.002, 17) + ..lineTo(18.002, 17) + ..cubicTo(19.107, 17, 20.002, 16.105, 20.002, 15) + ..lineTo(20.002, 7) + ..close() + // Outer shape + ..moveTo(22.002, 15) + ..cubicTo(22.002, 17.209, 20.211, 19, 18.002, 19) + ..lineTo(13.354, 19) + ..lineTo(8.627, 22.781) + ..cubicTo(8.243, 23.074, 7.683, 22.808, 7.627, 22.318) + ..lineTo(7.002, 22) + ..lineTo(7.002, 19) + ..lineTo(6.002, 19) + ..cubicTo(3.793, 19, 2.002, 17.209, 2.002, 15) + ..lineTo(2.002, 7) + ..cubicTo(2.002, 4.791, 3.793, 3, 6.002, 3) + ..lineTo(18.002, 3) + ..cubicTo(20.211, 3, 22.002, 4.791, 22.002, 7) + ..lineTo(22.002, 15) + ..close(); } canvas.drawPath(path, paint); diff --git a/lib/widgets/icons/share_icon.dart b/lib/widgets/icons/share_icon.dart index e344781..9f4a1c0 100644 --- a/lib/widgets/icons/share_icon.dart +++ b/lib/widgets/icons/share_icon.dart @@ -40,49 +40,48 @@ class _ShareIconPainter extends CustomPainter { final scale = size.width / 24.0; canvas.scale(scale); - final path = Path(); - // ArrowOutOfBoxModified_Stroke2_Corner2_Rounded path from Bluesky - // M20 13.75a1 1 0 0 1 1 1V18a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3v-3.25a1 1 0 1 1 2 0V18 - // a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-3.25a1 1 0 0 1 1-1ZM12 3a1 1 0 0 1 .707.293 - // l4.5 4.5a1 1 0 1 1-1.414 1.414L13 6.414v8.836a1 1 0 1 1-2 0V6.414 + // M20 13.75a1 1 0 0 1 1 1V18a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3v-3.25 + // a1 1 0 1 1 2 0V18 a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-3.25 + // a1 1 0 0 1 1-1ZM12 3a1 1 0 0 1 .707.293 l4.5 4.5 + // a1 1 0 1 1-1.414 1.414L13 6.414v8.836a1 1 0 1 1-2 0V6.414 // L8.207 9.207a1 1 0 1 1-1.414-1.414l4.5-4.5A1 1 0 0 1 12 3Z // Box bottom part - path.moveTo(20, 13.75); - path.cubicTo(20.552, 13.75, 21, 14.198, 21, 14.75); - path.lineTo(21, 18); - path.cubicTo(21, 19.657, 19.657, 21, 18, 21); - path.lineTo(6, 21); - path.cubicTo(4.343, 21, 3, 19.657, 3, 18); - path.lineTo(3, 14.75); - path.cubicTo(3, 14.198, 3.448, 13.75, 4, 13.75); - path.cubicTo(4.552, 13.75, 5, 14.198, 5, 14.75); - path.lineTo(5, 18); - path.cubicTo(5, 18.552, 5.448, 19, 6, 19); - path.lineTo(18, 19); - path.cubicTo(18.552, 19, 19, 18.552, 19, 18); - path.lineTo(19, 14.75); - path.cubicTo(19, 14.198, 19.448, 13.75, 20, 13.75); - path.close(); - - // Arrow - path.moveTo(12, 3); - path.cubicTo(12.265, 3, 12.52, 3.105, 12.707, 3.293); - path.lineTo(17.207, 7.793); - path.cubicTo(17.598, 8.184, 17.598, 8.817, 17.207, 9.207); - path.cubicTo(16.816, 9.598, 16.183, 9.598, 15.793, 9.207); - path.lineTo(13, 6.414); - path.lineTo(13, 15.25); - path.cubicTo(13, 15.802, 12.552, 16.25, 12, 16.25); - path.cubicTo(11.448, 16.25, 11, 15.802, 11, 15.25); - path.lineTo(11, 6.414); - path.lineTo(8.207, 9.207); - path.cubicTo(7.816, 9.598, 7.183, 9.598, 6.793, 9.207); - path.cubicTo(6.402, 8.816, 6.402, 8.183, 6.793, 7.793); - path.lineTo(11.293, 3.293); - path.cubicTo(11.48, 3.105, 11.735, 3, 12, 3); - path.close(); + final path = Path() + ..moveTo(20, 13.75) + ..cubicTo(20.552, 13.75, 21, 14.198, 21, 14.75) + ..lineTo(21, 18) + ..cubicTo(21, 19.657, 19.657, 21, 18, 21) + ..lineTo(6, 21) + ..cubicTo(4.343, 21, 3, 19.657, 3, 18) + ..lineTo(3, 14.75) + ..cubicTo(3, 14.198, 3.448, 13.75, 4, 13.75) + ..cubicTo(4.552, 13.75, 5, 14.198, 5, 14.75) + ..lineTo(5, 18) + ..cubicTo(5, 18.552, 5.448, 19, 6, 19) + ..lineTo(18, 19) + ..cubicTo(18.552, 19, 19, 18.552, 19, 18) + ..lineTo(19, 14.75) + ..cubicTo(19, 14.198, 19.448, 13.75, 20, 13.75) + ..close() + // Arrow + ..moveTo(12, 3) + ..cubicTo(12.265, 3, 12.52, 3.105, 12.707, 3.293) + ..lineTo(17.207, 7.793) + ..cubicTo(17.598, 8.184, 17.598, 8.817, 17.207, 9.207) + ..cubicTo(16.816, 9.598, 16.183, 9.598, 15.793, 9.207) + ..lineTo(13, 6.414) + ..lineTo(13, 15.25) + ..cubicTo(13, 15.802, 12.552, 16.25, 12, 16.25) + ..cubicTo(11.448, 16.25, 11, 15.802, 11, 15.25) + ..lineTo(11, 6.414) + ..lineTo(8.207, 9.207) + ..cubicTo(7.816, 9.598, 7.183, 9.598, 6.793, 9.207) + ..cubicTo(6.402, 8.816, 6.402, 8.183, 6.793, 7.793) + ..lineTo(11.293, 3.293) + ..cubicTo(11.48, 3.105, 11.735, 3, 12, 3) + ..close(); canvas.drawPath(path, paint); } diff --git a/lib/widgets/sign_in_dialog.dart b/lib/widgets/sign_in_dialog.dart new file mode 100644 index 0000000..08749f7 --- /dev/null +++ b/lib/widgets/sign_in_dialog.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import '../constants/app_colors.dart'; + +/// Sign In Dialog +/// +/// Shows a dialog prompting users to sign in before performing actions +/// that require authentication (like voting, commenting, etc.) +class SignInDialog extends StatelessWidget { + const SignInDialog({ + this.title = 'Sign in required', + this.message = 'You need to sign in to interact with posts.', + super.key, + }); + + final String title; + final String message; + + /// Show the dialog + static Future show( + BuildContext context, { + String? title, + String? message, + }) { + return showDialog( + context: context, + builder: + (context) => SignInDialog( + title: title ?? 'Sign in required', + message: message ?? 'You need to sign in to interact with posts.', + ), + ); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + backgroundColor: AppColors.background, + title: Text( + title, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + content: Text( + message, + style: const TextStyle(color: AppColors.textSecondary, fontSize: 14), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text( + 'Cancel', + style: TextStyle(color: AppColors.textSecondary), + ), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: AppColors.textPrimary, + ), + child: const Text('Sign In'), + ), + ], + ); + } +} diff --git a/test/providers/auth_provider_test.mocks.dart b/test/providers/auth_provider_test.mocks.dart index 1a18115..20942fe 100644 --- a/test/providers/auth_provider_test.mocks.dart +++ b/test/providers/auth_provider_test.mocks.dart @@ -84,7 +84,7 @@ class MockOAuthService extends _i1.Mock implements _i5.OAuthService { @override _i6.Future<_i2.OAuthSession?> restoreSession( String? did, { - dynamic refresh = 'auto', + String? refresh = 'auto', }) => (super.noSuchMethod( Invocation.method(#restoreSession, [did], {#refresh: refresh}), diff --git a/test/providers/feed_provider_test.dart b/test/providers/feed_provider_test.dart index 663b1d3..bec8b94 100644 --- a/test/providers/feed_provider_test.dart +++ b/test/providers/feed_provider_test.dart @@ -299,6 +299,22 @@ void main() { }); test('should not load more if already loading', () async { + when(mockAuthProvider.isAuthenticated).thenReturn(true); + + final response = TimelineResponse( + feed: [_createMockPost()], + cursor: 'cursor-1', + ); + + when( + mockApiService.getTimeline( + sort: anyNamed('sort'), + timeframe: anyNamed('timeframe'), + limit: anyNamed('limit'), + cursor: anyNamed('cursor'), + ), + ).thenAnswer((_) async => response); + await feedProvider.fetchTimeline(refresh: true); await feedProvider.loadMore(); diff --git a/test/widgets/animated_heart_icon_test.dart b/test/widgets/animated_heart_icon_test.dart new file mode 100644 index 0000000..c2dc74d --- /dev/null +++ b/test/widgets/animated_heart_icon_test.dart @@ -0,0 +1,333 @@ +import 'package:coves_flutter/widgets/icons/animated_heart_icon.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('AnimatedHeartIcon', () { + testWidgets('should render with default size', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AnimatedHeartIcon(isLiked: false), + ), + ), + ); + + // Widget should render + expect(find.byType(AnimatedHeartIcon), findsOneWidget); + + // Find the SizedBox that defines the size + final sizedBox = tester.widget( + find.descendant( + of: find.byType(AnimatedHeartIcon), + matching: find.byType(SizedBox), + ).first, + ); + + // Default size should be 18 + expect(sizedBox.width, 18); + expect(sizedBox.height, 18); + }); + + testWidgets('should render with custom size', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AnimatedHeartIcon(isLiked: false, size: 32), + ), + ), + ); + + // Find the SizedBox that defines the size + final sizedBox = tester.widget( + find.descendant( + of: find.byType(AnimatedHeartIcon), + matching: find.byType(SizedBox), + ).first, + ); + + // Custom size should be 32 + expect(sizedBox.width, 32); + expect(sizedBox.height, 32); + }); + + testWidgets('should use custom color when provided', (tester) async { + const customColor = Colors.blue; + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AnimatedHeartIcon( + isLiked: false, + color: customColor, + ), + ), + ), + ); + + // Widget should render with custom color + expect(find.byType(AnimatedHeartIcon), findsOneWidget); + // Note: We can't easily verify the color without accessing the CustomPainter, + // but we can verify the widget accepts the parameter + }); + + testWidgets('should use custom liked color when provided', (tester) async { + const customLikedColor = Colors.pink; + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AnimatedHeartIcon( + isLiked: true, + likedColor: customLikedColor, + ), + ), + ), + ); + + // Widget should render with custom liked color + expect(find.byType(AnimatedHeartIcon), findsOneWidget); + }); + + testWidgets('should start animation when isLiked changes to true', + (tester) async { + // Start with unliked state + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AnimatedHeartIcon(isLiked: false), + ), + ), + ); + + // Verify initial state + expect(find.byType(AnimatedHeartIcon), findsOneWidget); + + // Change to liked state + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AnimatedHeartIcon(isLiked: true), + ), + ), + ); + + // Pump frames to allow animation to start + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // Widget should still be present and animating + expect(find.byType(AnimatedHeartIcon), findsOneWidget); + }); + + testWidgets('should not animate when isLiked changes to false', + (tester) async { + // Start with liked state + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AnimatedHeartIcon(isLiked: true), + ), + ), + ); + + await tester.pump(); + + // Change to unliked state + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AnimatedHeartIcon(isLiked: false), + ), + ), + ); + + await tester.pump(); + + // Widget should update without error + expect(find.byType(AnimatedHeartIcon), findsOneWidget); + }); + + testWidgets('should complete animation after duration', (tester) async { + // Start with unliked state + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AnimatedHeartIcon(isLiked: false), + ), + ), + ); + + // Change to liked state + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AnimatedHeartIcon(isLiked: true), + ), + ), + ); + + // Pump through the entire animation duration (800ms) + await tester.pump(); + await tester.pump(const Duration(milliseconds: 800)); + await tester.pumpAndSettle(); + + // Widget should still be present after animation completes + expect(find.byType(AnimatedHeartIcon), findsOneWidget); + }); + + testWidgets('should handle rapid state changes', (tester) async { + // Start with unliked state + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AnimatedHeartIcon(isLiked: false), + ), + ), + ); + + // Rapidly toggle states + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AnimatedHeartIcon(isLiked: true), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 50)); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AnimatedHeartIcon(isLiked: false), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 50)); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AnimatedHeartIcon(isLiked: true), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 50)); + + // Widget should handle rapid changes without error + expect(find.byType(AnimatedHeartIcon), findsOneWidget); + }); + + testWidgets('should use OverflowBox to allow animation overflow', + (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AnimatedHeartIcon(isLiked: true), + ), + ), + ); + + // Find the OverflowBox + expect(find.byType(OverflowBox), findsOneWidget); + + final overflowBox = tester.widget( + find.byType(OverflowBox), + ); + + // OverflowBox should have larger max dimensions (2.5x the icon size) + // to accommodate the 1.3x scale and particle burst + expect(overflowBox.maxWidth, 18 * 2.5); + expect(overflowBox.maxHeight, 18 * 2.5); + }); + + testWidgets('should render CustomPaint for heart icon', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AnimatedHeartIcon(isLiked: false), + ), + ), + ); + + // Find the CustomPaint widget (used for rendering the heart) + expect(find.byType(CustomPaint), findsAtLeastNWidgets(1)); + }); + + testWidgets('should not animate on initial render when isLiked is true', + (tester) async { + // Render with isLiked=true initially + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AnimatedHeartIcon(isLiked: true), + ), + ), + ); + + await tester.pump(); + + // Widget should render in liked state without animation + // (Animation only triggers on state change, not initial render) + expect(find.byType(AnimatedHeartIcon), findsOneWidget); + }); + + testWidgets('should dispose controller properly', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AnimatedHeartIcon(isLiked: false), + ), + ), + ); + + // Remove the widget + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SizedBox.shrink(), + ), + ), + ); + + // Should dispose without error + // (No assertions needed - test passes if no exception is thrown) + }); + + testWidgets('should rebuild when isLiked changes', (tester) async { + var buildCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + buildCount++; + return const AnimatedHeartIcon(isLiked: false); + }, + ), + ), + ), + ); + + final initialBuildCount = buildCount; + + // Change isLiked state + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + buildCount++; + return const AnimatedHeartIcon(isLiked: true); + }, + ), + ), + ), + ); + + // Should rebuild + expect(buildCount, greaterThan(initialBuildCount)); + }); + }); +} diff --git a/test/widgets/feed_screen_test.mocks.dart b/test/widgets/feed_screen_test.mocks.dart deleted file mode 100644 index 5128c59..0000000 --- a/test/widgets/feed_screen_test.mocks.dart +++ /dev/null @@ -1,252 +0,0 @@ -// Mocks generated by Mockito 5.4.6 from annotations -// in coves_flutter/test/widgets/feed_screen_test.dart. -// Do not manually edit this file. - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i3; -import 'dart:ui' as _i4; - -import 'package:coves_flutter/models/post.dart' as _i6; -import 'package:coves_flutter/providers/auth_provider.dart' as _i2; -import 'package:coves_flutter/providers/feed_provider.dart' as _i5; -import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i7; - -// ignore_for_file: type=lint -// ignore_for_file: avoid_redundant_argument_values -// ignore_for_file: avoid_setters_without_getters -// ignore_for_file: comment_references -// ignore_for_file: deprecated_member_use -// ignore_for_file: deprecated_member_use_from_same_package -// ignore_for_file: implementation_imports -// ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: must_be_immutable -// ignore_for_file: prefer_const_constructors -// ignore_for_file: unnecessary_parenthesis -// ignore_for_file: camel_case_types -// ignore_for_file: subtype_of_sealed_class -// ignore_for_file: invalid_use_of_internal_member - -/// A class which mocks [AuthProvider]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockAuthProvider extends _i1.Mock implements _i2.AuthProvider { - MockAuthProvider() { - _i1.throwOnMissingStub(this); - } - - @override - bool get isAuthenticated => - (super.noSuchMethod( - Invocation.getter(#isAuthenticated), - returnValue: false, - ) - as bool); - - @override - bool get isLoading => - (super.noSuchMethod(Invocation.getter(#isLoading), returnValue: false) - as bool); - - @override - bool get hasListeners => - (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) - as bool); - - @override - _i3.Future getAccessToken() => - (super.noSuchMethod( - Invocation.method(#getAccessToken, []), - returnValue: _i3.Future.value(), - ) - as _i3.Future); - - @override - _i3.Future initialize() => - (super.noSuchMethod( - Invocation.method(#initialize, []), - returnValue: _i3.Future.value(), - returnValueForMissingStub: _i3.Future.value(), - ) - as _i3.Future); - - @override - _i3.Future signIn(String? handle) => - (super.noSuchMethod( - Invocation.method(#signIn, [handle]), - returnValue: _i3.Future.value(), - returnValueForMissingStub: _i3.Future.value(), - ) - as _i3.Future); - - @override - _i3.Future signOut() => - (super.noSuchMethod( - Invocation.method(#signOut, []), - returnValue: _i3.Future.value(), - returnValueForMissingStub: _i3.Future.value(), - ) - as _i3.Future); - - @override - void clearError() => super.noSuchMethod( - Invocation.method(#clearError, []), - returnValueForMissingStub: null, - ); - - @override - void dispose() => super.noSuchMethod( - Invocation.method(#dispose, []), - returnValueForMissingStub: null, - ); - - @override - void addListener(_i4.VoidCallback? listener) => super.noSuchMethod( - Invocation.method(#addListener, [listener]), - returnValueForMissingStub: null, - ); - - @override - void removeListener(_i4.VoidCallback? listener) => super.noSuchMethod( - Invocation.method(#removeListener, [listener]), - returnValueForMissingStub: null, - ); - - @override - void notifyListeners() => super.noSuchMethod( - Invocation.method(#notifyListeners, []), - returnValueForMissingStub: null, - ); -} - -/// A class which mocks [FeedProvider]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockFeedProvider extends _i1.Mock implements _i5.FeedProvider { - MockFeedProvider() { - _i1.throwOnMissingStub(this); - } - - @override - List<_i6.FeedViewPost> get posts => - (super.noSuchMethod( - Invocation.getter(#posts), - returnValue: <_i6.FeedViewPost>[], - ) - as List<_i6.FeedViewPost>); - - @override - bool get isLoading => - (super.noSuchMethod(Invocation.getter(#isLoading), returnValue: false) - as bool); - - @override - bool get isLoadingMore => - (super.noSuchMethod(Invocation.getter(#isLoadingMore), returnValue: false) - as bool); - - @override - bool get hasMore => - (super.noSuchMethod(Invocation.getter(#hasMore), returnValue: false) - as bool); - - @override - String get sort => - (super.noSuchMethod( - Invocation.getter(#sort), - returnValue: _i7.dummyValue(this, Invocation.getter(#sort)), - ) - as String); - - @override - bool get hasListeners => - (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) - as bool); - - @override - _i3.Future loadFeed({bool? refresh = false}) => - (super.noSuchMethod( - Invocation.method(#loadFeed, [], {#refresh: refresh}), - returnValue: _i3.Future.value(), - returnValueForMissingStub: _i3.Future.value(), - ) - as _i3.Future); - - @override - _i3.Future fetchTimeline({bool? refresh = false}) => - (super.noSuchMethod( - Invocation.method(#fetchTimeline, [], {#refresh: refresh}), - returnValue: _i3.Future.value(), - returnValueForMissingStub: _i3.Future.value(), - ) - as _i3.Future); - - @override - _i3.Future fetchDiscover({bool? refresh = false}) => - (super.noSuchMethod( - Invocation.method(#fetchDiscover, [], {#refresh: refresh}), - returnValue: _i3.Future.value(), - returnValueForMissingStub: _i3.Future.value(), - ) - as _i3.Future); - - @override - _i3.Future loadMore() => - (super.noSuchMethod( - Invocation.method(#loadMore, []), - returnValue: _i3.Future.value(), - returnValueForMissingStub: _i3.Future.value(), - ) - as _i3.Future); - - @override - void setSort(String? newSort, {String? newTimeframe}) => super.noSuchMethod( - Invocation.method(#setSort, [newSort], {#newTimeframe: newTimeframe}), - returnValueForMissingStub: null, - ); - - @override - _i3.Future retry() => - (super.noSuchMethod( - Invocation.method(#retry, []), - returnValue: _i3.Future.value(), - returnValueForMissingStub: _i3.Future.value(), - ) - as _i3.Future); - - @override - void clearError() => super.noSuchMethod( - Invocation.method(#clearError, []), - returnValueForMissingStub: null, - ); - - @override - void reset() => super.noSuchMethod( - Invocation.method(#reset, []), - returnValueForMissingStub: null, - ); - - @override - void dispose() => super.noSuchMethod( - Invocation.method(#dispose, []), - returnValueForMissingStub: null, - ); - - @override - void addListener(_i4.VoidCallback? listener) => super.noSuchMethod( - Invocation.method(#addListener, [listener]), - returnValueForMissingStub: null, - ); - - @override - void removeListener(_i4.VoidCallback? listener) => super.noSuchMethod( - Invocation.method(#removeListener, [listener]), - returnValueForMissingStub: null, - ); - - @override - void notifyListeners() => super.noSuchMethod( - Invocation.method(#notifyListeners, []), - returnValueForMissingStub: null, - ); -} diff --git a/test/widgets/sign_in_dialog_test.dart b/test/widgets/sign_in_dialog_test.dart new file mode 100644 index 0000000..b357c52 --- /dev/null +++ b/test/widgets/sign_in_dialog_test.dart @@ -0,0 +1,231 @@ +import 'package:coves_flutter/widgets/sign_in_dialog.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('SignInDialog', () { + testWidgets('should display default title and message', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + return ElevatedButton( + onPressed: () => SignInDialog.show(context), + child: const Text('Show Dialog'), + ); + }, + ), + ), + ), + ); + + // Tap button to show dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Verify default title and message + expect(find.text('Sign in required'), findsOneWidget); + expect( + find.text('You need to sign in to interact with posts.'), + findsOneWidget, + ); + }); + + testWidgets('should display custom title and message', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + return ElevatedButton( + onPressed: () => SignInDialog.show( + context, + title: 'Custom Title', + message: 'Custom message here', + ), + child: const Text('Show Dialog'), + ); + }, + ), + ), + ), + ); + + // Tap button to show dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Verify custom title and message + expect(find.text('Custom Title'), findsOneWidget); + expect(find.text('Custom message here'), findsOneWidget); + }); + + testWidgets('should have Cancel and Sign In buttons', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + return ElevatedButton( + onPressed: () => SignInDialog.show(context), + child: const Text('Show Dialog'), + ); + }, + ), + ), + ), + ); + + // Tap button to show dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Verify buttons exist + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('Sign In'), findsOneWidget); + }); + + testWidgets('should return false when Cancel is tapped', (tester) async { + bool? result; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + return ElevatedButton( + onPressed: () async { + result = await SignInDialog.show(context); + }, + child: const Text('Show Dialog'), + ); + }, + ), + ), + ), + ); + + // Tap button to show dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Tap Cancel button + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + // Verify result is false + expect(result, false); + + // Dialog should be dismissed + expect(find.text('Sign in required'), findsNothing); + }); + + testWidgets('should return true when Sign In is tapped', (tester) async { + bool? result; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + return ElevatedButton( + onPressed: () async { + result = await SignInDialog.show(context); + }, + child: const Text('Show Dialog'), + ); + }, + ), + ), + ), + ); + + // Tap button to show dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Tap Sign In button + await tester.tap(find.text('Sign In')); + await tester.pumpAndSettle(); + + // Verify result is true + expect(result, true); + + // Dialog should be dismissed + expect(find.text('Sign in required'), findsNothing); + }); + + testWidgets('should dismiss when tapped outside (barrier)', (tester) async { + bool? result; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + return ElevatedButton( + onPressed: () async { + result = await SignInDialog.show(context); + }, + child: const Text('Show Dialog'), + ); + }, + ), + ), + ), + ); + + // Tap button to show dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Tap outside the dialog (on the barrier) + await tester.tapAt(const Offset(10, 10)); + await tester.pumpAndSettle(); + + // Verify result is null (dismissed without selecting an option) + expect(result, null); + + // Dialog should be dismissed + expect(find.text('Sign in required'), findsNothing); + }); + + testWidgets('should use app colors for styling', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + return ElevatedButton( + onPressed: () => SignInDialog.show(context), + child: const Text('Show Dialog'), + ); + }, + ), + ), + ), + ); + + // Tap button to show dialog + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + // Find the AlertDialog widget + final alertDialog = tester.widget( + find.byType(AlertDialog), + ); + + // Verify background color is set + expect(alertDialog.backgroundColor, isNotNull); + + // Find the Sign In button + final signInButton = tester.widget( + find.widgetWithText(ElevatedButton, 'Sign In'), + ); + + // Verify button styling + expect(signInButton.style, isNotNull); + }); + }); +}