feat: add UI components and feed improvements

Added Bluesky-inspired UI components and fixed feed provider issues.

**New Components**:
- AnimatedHeartIcon: Animated like button with burst effect
- 3-phase animation: shrink → expand → settle
- Particle burst at peak expansion
- Bluesky-style filled/outline states
- ReplyIcon: Reply icon with filled/outline states
- ShareIcon: Share/upload icon with Bluesky styling
- SignInDialog: Reusable authentication prompt dialog
- Shows when unauthenticated users try to interact
- Provides clear call-to-action for sign-in

**Feed Provider Fixes**:
- Fixed duplicate API calls on failed sign-in attempt
- Track auth state transitions instead of current state
- Only reload feed when transitioning authenticated → unauthenticated
- Prevents unnecessary API calls and improves performance

**API Exception Enhancements**:
- Enhanced error hierarchy with specific exception types
- Better error message extraction from API responses
- Improved Dio integration for network errors

**Configuration**:
- Added EnvironmentConfig for centralized app settings
- Updated OAuth config with better documentation

**Testing**:
- Added AnimatedHeartIcon widget tests
- Added SignInDialog widget tests
- Updated feed provider tests for auth transition logic
- Removed obsolete mock files

All tests passing, flutter analyze clean (12 info-level style suggestions).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

+67
lib/config/environment_config.dart
···
···
+
/// 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;
+
}
+4 -3
lib/config/oauth_config.dart
···
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
/// OAuth Configuration for atProto
///
/// This configuration provides ClientMetadata for the new
···
'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';
// Derived OAuth URLs
static const String clientId = '$oauthServerUrl/client-metadata.json';
···
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
+
import 'environment_config.dart';
+
/// OAuth Configuration for atProto
///
/// This configuration provides ClientMetadata for the new
···
'dev.workers.brettmay0212.lingering-darkness-50a6';
// API Configuration
+
// Environment-aware API URL
+
static String get apiUrl => EnvironmentConfig.current.apiUrl;
// Derived OAuth URLs
static const String clientId = '$oauthServerUrl/client-metadata.json';
+17 -7
lib/providers/feed_provider.dart
···
apiService ??
CovesApiService(tokenGetter: _authProvider.getAccessToken);
// [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.
···
/// 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.
void _onAuthChanged() {
-
if (!_authProvider.isAuthenticated && _posts.isNotEmpty) {
if (kDebugMode) {
-
debugPrint('🔒 Auth state changed to unauthenticated - clearing feed');
}
reset();
// Automatically load the public discover feed
loadFeed(refresh: true);
}
}
final AuthProvider _authProvider;
late final CovesApiService _apiService;
// Feed state
List<FeedViewPost> _posts = [];
···
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.
···
/// Handle authentication state changes
///
+
/// 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() {
+
final isAuthenticated = _authProvider.isAuthenticated;
+
+
// Only reload if transitioning from authenticated → unauthenticated
+
if (_wasAuthenticated && !isAuthenticated && _posts.isNotEmpty) {
if (kDebugMode) {
+
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<FeedViewPost> _posts = [];
+49
lib/services/api_exceptions.dart
···
/// This allows better error handling and user-friendly error messages.
library;
/// Base class for all API exceptions
class ApiException implements Exception {
ApiException(this.message, {this.statusCode, this.originalError});
final String message;
final int? statusCode;
final dynamic originalError;
···
/// 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;
+12
lib/services/oauth_service.dart
···
import 'dart:async';
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
import 'package:flutter/foundation.dart';
import '../config/oauth_config.dart';
/// OAuth Service for atProto authentication using the new
···
/// - Automatic session management
Future<void> initialize() async {
try {
// Create client with metadata from config
_client = FlutterOAuthClient(
clientMetadata: OAuthConfig.createClientMetadata(),
);
// Set up session event listeners
···
if (kDebugMode) {
print('✅ FlutterOAuthClient initialized');
print(' Client ID: ${OAuthConfig.clientId}');
print(' Redirect URI: ${OAuthConfig.customSchemeCallback}');
print(' Scope: ${OAuthConfig.scope}');
}
} catch (e) {
if (kDebugMode) {
···
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
···
/// - Automatic session management
Future<void> 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
···
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) {
+10 -5
lib/services/pds_discovery_service.dart
···
import 'package:dio/dio.dart';
/// PDS Discovery Service
///
/// Handles the resolution of atProto handles to their Personal Data
···
/// redirect them to THEIR PDS's OAuth server.
///
/// Flow:
-
/// 1. Resolve handle to DID using a handle resolver (bsky.social)
/// 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 {
final Dio _dio = Dio();
/// Discover the PDS URL for a given atProto handle
///
···
/// Resolve an atProto handle to a DID
///
-
/// Uses Bluesky's public resolver which can resolve ANY atProto handle,
-
/// not just bsky.social handles.
Future<String> _resolveHandle(String handle) async {
try {
final response = await _dio.get(
-
'https://bsky.social/xrpc/com.atproto.identity.resolveHandle',
queryParameters: {'handle': handle},
);
···
/// Fetch a DID document from the PLC directory
Future<Map<String, dynamic>> _fetchDIDDocument(String did) async {
try {
-
final response = await _dio.get('https://plc.directory/$did');
if (response.statusCode != 200) {
throw Exception('Failed to fetch DID document: ${response.statusCode}');
···
import 'package:dio/dio.dart';
+
import '../config/environment_config.dart';
+
/// PDS Discovery Service
///
/// Handles the resolution of atProto handles to their Personal Data
···
/// redirect them to THEIR PDS's OAuth server.
///
/// Flow:
+
/// 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
///
···
/// Resolve an atProto handle to a DID
///
+
/// Uses configured handle resolver (production: Bluesky, local: your PDS)
Future<String> _resolveHandle(String handle) async {
try {
final response = await _dio.get(
+
_config.handleResolverUrl,
queryParameters: {'handle': handle},
);
···
/// Fetch a DID document from the PLC directory
Future<Map<String, dynamic>> _fetchDIDDocument(String did) async {
try {
+
final response = await _dio.get('${_config.plcDirectoryUrl}/$did');
if (response.statusCode != 200) {
throw Exception('Failed to fetch DID document: ${response.statusCode}');
+34 -31
lib/widgets/icons/animated_heart_icon.dart
···
_hasBeenToggled = true;
_previousIsLiked = widget.isLiked;
-
if (widget.isLiked) {
_controller.forward(from: 0);
}
}
···
}
double _getHeartScale() {
-
if (!widget.isLiked || !_hasBeenToggled) return 1;
final progress = _controller.value;
if (progress < 0.15) {
···
final particles = <Widget>[];
final containerSize = widget.size * 2.5;
-
for (int 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;
···
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();
} 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();
}
canvas.drawPath(path, paint);
···
_hasBeenToggled = true;
_previousIsLiked = widget.isLiked;
+
if (widget.isLiked && mounted) {
_controller.forward(from: 0);
}
}
···
}
double _getHeartScale() {
+
if (!widget.isLiked || !_hasBeenToggled) {
+
return 1;
+
}
final progress = _controller.value;
if (progress < 0.15) {
···
final particles = <Widget>[];
final containerSize = widget.size * 2.5;
+
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;
···
if (filled) {
// Filled heart path from Bluesky
+
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)
+
..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);
+52 -50
lib/widgets/icons/reply_icon.dart
···
// 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();
} 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
// 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();
}
canvas.drawPath(path, paint);
···
// 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)
+
..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 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)
+
..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);
+38 -39
lib/widgets/icons/share_icon.dart
···
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
// 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();
canvas.drawPath(path, paint);
}
···
final scale = size.width / 24.0;
canvas.scale(scale);
// 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.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
+
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);
}
+70
lib/widgets/sign_in_dialog.dart
···
···
+
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<bool?> show(
+
BuildContext context, {
+
String? title,
+
String? message,
+
}) {
+
return showDialog<bool>(
+
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'),
+
),
+
],
+
);
+
}
+
}
+1 -1
test/providers/auth_provider_test.mocks.dart
···
@override
_i6.Future<_i2.OAuthSession?> restoreSession(
String? did, {
-
dynamic refresh = 'auto',
}) =>
(super.noSuchMethod(
Invocation.method(#restoreSession, [did], {#refresh: refresh}),
···
@override
_i6.Future<_i2.OAuthSession?> restoreSession(
String? did, {
+
String? refresh = 'auto',
}) =>
(super.noSuchMethod(
Invocation.method(#restoreSession, [did], {#refresh: refresh}),
+16
test/providers/feed_provider_test.dart
···
});
test('should not load more if already loading', () async {
await feedProvider.fetchTimeline(refresh: true);
await feedProvider.loadMore();
···
});
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();
+333
test/widgets/animated_heart_icon_test.dart
···
···
+
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<SizedBox>(
+
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<SizedBox>(
+
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<OverflowBox>(
+
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));
+
});
+
});
+
}
-252
test/widgets/feed_screen_test.mocks.dart
···
-
// 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<String?> getAccessToken() =>
-
(super.noSuchMethod(
-
Invocation.method(#getAccessToken, []),
-
returnValue: _i3.Future<String?>.value(),
-
)
-
as _i3.Future<String?>);
-
-
@override
-
_i3.Future<void> initialize() =>
-
(super.noSuchMethod(
-
Invocation.method(#initialize, []),
-
returnValue: _i3.Future<void>.value(),
-
returnValueForMissingStub: _i3.Future<void>.value(),
-
)
-
as _i3.Future<void>);
-
-
@override
-
_i3.Future<void> signIn(String? handle) =>
-
(super.noSuchMethod(
-
Invocation.method(#signIn, [handle]),
-
returnValue: _i3.Future<void>.value(),
-
returnValueForMissingStub: _i3.Future<void>.value(),
-
)
-
as _i3.Future<void>);
-
-
@override
-
_i3.Future<void> signOut() =>
-
(super.noSuchMethod(
-
Invocation.method(#signOut, []),
-
returnValue: _i3.Future<void>.value(),
-
returnValueForMissingStub: _i3.Future<void>.value(),
-
)
-
as _i3.Future<void>);
-
-
@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<String>(this, Invocation.getter(#sort)),
-
)
-
as String);
-
-
@override
-
bool get hasListeners =>
-
(super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false)
-
as bool);
-
-
@override
-
_i3.Future<void> loadFeed({bool? refresh = false}) =>
-
(super.noSuchMethod(
-
Invocation.method(#loadFeed, [], {#refresh: refresh}),
-
returnValue: _i3.Future<void>.value(),
-
returnValueForMissingStub: _i3.Future<void>.value(),
-
)
-
as _i3.Future<void>);
-
-
@override
-
_i3.Future<void> fetchTimeline({bool? refresh = false}) =>
-
(super.noSuchMethod(
-
Invocation.method(#fetchTimeline, [], {#refresh: refresh}),
-
returnValue: _i3.Future<void>.value(),
-
returnValueForMissingStub: _i3.Future<void>.value(),
-
)
-
as _i3.Future<void>);
-
-
@override
-
_i3.Future<void> fetchDiscover({bool? refresh = false}) =>
-
(super.noSuchMethod(
-
Invocation.method(#fetchDiscover, [], {#refresh: refresh}),
-
returnValue: _i3.Future<void>.value(),
-
returnValueForMissingStub: _i3.Future<void>.value(),
-
)
-
as _i3.Future<void>);
-
-
@override
-
_i3.Future<void> loadMore() =>
-
(super.noSuchMethod(
-
Invocation.method(#loadMore, []),
-
returnValue: _i3.Future<void>.value(),
-
returnValueForMissingStub: _i3.Future<void>.value(),
-
)
-
as _i3.Future<void>);
-
-
@override
-
void setSort(String? newSort, {String? newTimeframe}) => super.noSuchMethod(
-
Invocation.method(#setSort, [newSort], {#newTimeframe: newTimeframe}),
-
returnValueForMissingStub: null,
-
);
-
-
@override
-
_i3.Future<void> retry() =>
-
(super.noSuchMethod(
-
Invocation.method(#retry, []),
-
returnValue: _i3.Future<void>.value(),
-
returnValueForMissingStub: _i3.Future<void>.value(),
-
)
-
as _i3.Future<void>);
-
-
@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,
-
);
-
}
···
+231
test/widgets/sign_in_dialog_test.dart
···
···
+
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<AlertDialog>(
+
find.byType(AlertDialog),
+
);
+
+
// Verify background color is set
+
expect(alertDialog.backgroundColor, isNotNull);
+
+
// Find the Sign In button
+
final signInButton = tester.widget<ElevatedButton>(
+
find.widgetWithText(ElevatedButton, 'Sign In'),
+
);
+
+
// Verify button styling
+
expect(signInButton.style, isNotNull);
+
});
+
});
+
}