diff --git a/backend/automations/serializers.py b/backend/automations/serializers.py index 33a2ad94..0d0b4e10 100755 --- a/backend/automations/serializers.py +++ b/backend/automations/serializers.py @@ -448,11 +448,12 @@ class AboutServiceSerializer(serializers.ModelSerializer): actions = AboutActionSerializer(many=True, read_only=True) reactions = AboutReactionSerializer(many=True, read_only=True) + requires_oauth = serializers.SerializerMethodField() logo = serializers.SerializerMethodField() class Meta: model = Service - fields = ["name", "actions", "reactions", "logo"] + fields = ["name", "requires_oauth", "actions", "reactions", "logo"] def get_logo(self, obj): """Return the configured logo URL for the service or a fallback. @@ -509,6 +510,30 @@ def _normalize_key(s: str) -> str: return found + def get_requires_oauth(self, obj): + """Check if this service requires OAuth authentication. + + Services that require OAuth: + - Direct OAuth providers (google, github, slack, spotify, twitch) + - Services that map to OAuth tokens (gmail->google, google_calendar->google) + """ + from django.conf import settings + + oauth_names = settings.OAUTH2_PROVIDERS.keys() + + # Direct OAuth providers + if obj.name in oauth_names: + return True + + # Services that map to OAuth tokens + service_oauth_map = { + "gmail": "google", + "google_calendar": "google", + } + + mapped_oauth = service_oauth_map.get(obj.name) + return mapped_oauth in oauth_names if mapped_oauth else False + # Serializers pour Execution (journaling) diff --git a/backend/automations/tasks.py b/backend/automations/tasks.py index 33bbe74e..292a300a 100755 --- a/backend/automations/tasks.py +++ b/backend/automations/tasks.py @@ -674,7 +674,14 @@ def check_gmail_actions(self): try: # Get all active Areas with Gmail actions - gmail_areas = get_active_areas(service_name="gmail") + gmail_areas = get_active_areas( + [ + "gmail_new_email", + "gmail_new_from_sender", + "gmail_new_with_label", + "gmail_new_with_subject", + ] + ) if not gmail_areas: logger.info("No active Gmail areas found") diff --git a/backend/automations/urls.py b/backend/automations/urls.py index 3ffc81e0..1cb79c70 100755 --- a/backend/automations/urls.py +++ b/backend/automations/urls.py @@ -101,4 +101,9 @@ views.DebugExecutionsView.as_view({"get": "list"}), name="debug-executions", ), + path( + "api/compatibility-rules/", + views.CompatibilityRulesView.as_view({"get": "list"}), + name="compatibility-rules", + ), ] diff --git a/backend/automations/views.py b/backend/automations/views.py index 1fd9668a..e711011f 100755 --- a/backend/automations/views.py +++ b/backend/automations/views.py @@ -688,3 +688,28 @@ def list(self, request, area_id=None): {"error": "Area not found or access denied"}, status=status.HTTP_404_NOT_FOUND, ) + + +# Compatibility Rules View +# ============================================================================ + + +@extend_schema( + tags=["Compatibility"], + summary="Get action-reaction compatibility rules", + description="Returns the compatibility rules mapping actions to compatible reactions. Public endpoint.", +) +class CompatibilityRulesView(viewsets.ViewSet): + """Returns compatibility rules for action-reaction combinations.""" + + permission_classes = [permissions.AllowAny] + + def list(self, request): + """ + Get all compatibility rules. + + GET /api/compatibility-rules/ + """ + from .validators import COMPATIBILITY_RULES + + return Response(COMPATIBILITY_RULES, status=status.HTTP_200_OK) diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 8e52789c..c4662b45 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -46,6 +46,19 @@ + + + + + + + + + + + + + diff --git a/mobile/lib/config/api_config.dart b/mobile/lib/config/api_config.dart index b97e2ed6..4284c267 100644 --- a/mobile/lib/config/api_config.dart +++ b/mobile/lib/config/api_config.dart @@ -10,14 +10,20 @@ class ApiConfig { if (portString.isNotEmpty) { _configuredPort = int.tryParse(portString); if (_configuredPort != null) { - ApiConfig.debugPrint('Backend port configured: $_configuredPort'); + ApiConfig.debugPrint('✅ Backend port configured: $_configuredPort'); + } else { + ApiConfig.debugPrint('❌ Failed to parse BACKEND_PORT: "$portString"'); } + } else { + ApiConfig.debugPrint( + '⚠️ BACKEND_PORT is empty, using default: ${AppConfig.defaultPort}', + ); } } static void setPort(int port) { _configuredPort = port; - ApiConfig.debugPrint('Backend port set to: $port'); + ApiConfig.debugPrint('✅ Backend port set to: $port'); } static String? _overrideBaseUrl; @@ -26,7 +32,7 @@ class ApiConfig { static void setBaseUrl(String url) { _overrideBaseUrl = url.trim(); - ApiConfig.debugPrint('Base URL override set to: $_overrideBaseUrl'); + ApiConfig.debugPrint('✅ Base URL override set to: $_overrideBaseUrl'); } static void forceAndroidLocalhost(bool enable) { @@ -44,21 +50,37 @@ class ApiConfig { if (kIsWeb) { // Web always uses localhost host = AppConfig.defaultHost; + ApiConfig.debugPrint('🌐 Detected: Web - using localhost'); } else if (Platform.isAndroid && !_forceAndroidLocalhost) { // Android emulator needs special host IP host = AppConfig.androidEmulatorHost; + ApiConfig.debugPrint('🌐 Detected: Android emulator - using 10.0.2.2'); } else { // iOS, physical devices, or forced localhost host = AppConfig.defaultHost; + ApiConfig.debugPrint( + '🌐 Detected: Physical device/iOS - using localhost', + ); } - return _autoDetectedBase = 'http://$host:$port'; + final baseUrl = 'http://$host:$port'; + ApiConfig.debugPrint('🌐 Base URL: $baseUrl'); + return _autoDetectedBase = baseUrl; } static String get baseUrl => _overrideBaseUrl ?? _detectBaseUrl(); - static String get authBaseUrl => '$baseUrl/${AppConfig.authPrefix}'; - static String get apiBaseUrl => '$baseUrl/${AppConfig.apiPrefix}'; + static String get authBaseUrl { + final url = '$baseUrl/${AppConfig.authPrefix}'; + ApiConfig.debugPrint('🔐 Auth base URL: $url'); + return url; + } + + static String get apiBaseUrl { + final url = '$baseUrl/${AppConfig.apiPrefix}'; + ApiConfig.debugPrint('📡 API base URL: $url'); + return url; + } static String get loginUrl => '$authBaseUrl/login/'; static String get registerUrl => '$authBaseUrl/register/'; @@ -78,9 +100,18 @@ class ApiConfig { static String get statisticsUrl => '$authBaseUrl/statistics'; static String get userStatisticsUrl => '$apiBaseUrl/users/statistics/'; - static String get googleLoginUrl => '$authBaseUrl/google-login/'; + static String get googleLoginUrl { + final url = '$authBaseUrl/google-login/'; + ApiConfig.debugPrint('🔑 Google Login URL: $url'); + return url; + } + + static String automationUrl(int id) { + final url = '$apiBaseUrl/areas/$id/'; + ApiConfig.debugPrint('📋 Automation URL ($id): $url'); + return url; + } - static String automationUrl(int id) => '$apiBaseUrl/areas/$id/'; static String automationDuplicateUrl(int id) => '$apiBaseUrl/areas/$id/duplicate/'; static String automationPauseUrl(int id) => '$apiBaseUrl/areas/$id/pause/'; diff --git a/mobile/lib/config/service_provider_config.dart b/mobile/lib/config/service_provider_config.dart index edd6d800..73635cfc 100644 --- a/mobile/lib/config/service_provider_config.dart +++ b/mobile/lib/config/service_provider_config.dart @@ -1,30 +1,14 @@ +import '../utils/service_token_mapper.dart'; + /// Service Provider Configuration /// -/// Centralized configuration for all OAuth service providers (Google, GitHub, etc.) +/// Lightweight configuration for service-related utilities. +/// Most service metadata (logos, OAuth info) comes from about.json class ServiceProviderConfig { - static const List oauthServices = [ - 'github', - 'gmail', - 'google', - 'slack', - 'twitch', - ]; - - static bool requiresOAuth(String serviceName) { - // Map gmail to google for OAuth purposes - final mappedName = mapServiceName(serviceName); - return oauthServices.contains(mappedName.toLowerCase()); - } - - /// Map service names for consistency (gmail -> google) + /// Map service names for consistency (gmail/google_calendar -> google) /// This is used to normalize service names across the application static String mapServiceName(String serviceName) { - switch (serviceName.toLowerCase()) { - case 'gmail': - return 'google'; - default: - return serviceName; - } + return ServiceTokenMapper.resolveTokenService(serviceName); } /// Get user-friendly provider name @@ -37,33 +21,12 @@ class ServiceProviderConfig { return 'GitHub'; case 'slack': return 'Slack'; + case 'spotify': + return 'Spotify'; case 'twitch': return 'Twitch'; default: return provider; } } - - /// Get provider icon URL from web (PNG images) - static String getIconUrl(String provider) { - final mappedProvider = mapServiceName(provider); - switch (mappedProvider.toLowerCase()) { - case 'github': - return 'https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Flogos-world.net%2Fwp-content%2Fuploads%2F2020%2F11%2FGitHub-Symbol.png&f=1&nofb=1&ipt=1e8fe0d0c31dac1d47abf59a23130ec2b31975a34721d1ea9284db059ba4a957'; - case 'google': - return 'https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/Google_2015_logo.svg/256px-Google_2015_logo.svg.png'; - case 'slack': - return 'https://cdn-icons-png.flaticon.com/512/2111/2111615.png'; - case 'twitch': - return 'https://cdn-icons-png.flaticon.com/512/5968/5968819.png'; - case 'timer': - return 'https://cdn-icons-png.flaticon.com/512/109/109613.png'; - case 'email': - return 'https://cdn-icons-png.flaticon.com/512/542/542638.png'; - case 'webhook': - return 'https://cdn-icons-png.flaticon.com/512/149/149852.png'; - default: - return 'https://cdn-icons-png.flaticon.com/512/1828/1828970.png'; - } - } } diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index efc8be5e..5774973d 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import 'dart:async'; import 'package:app_links/app_links.dart'; import 'providers/providers.dart'; +import 'providers/compatibility_provider.dart'; import 'pages/splash_page.dart'; import 'pages/login_page.dart'; import 'pages/reset_password_page.dart'; @@ -21,9 +22,9 @@ void main() async { try { AppConfig.validateEnvironment(); - AppConfig.debugPrint('Environment validation passed'); + debugPrint('✅ Environment validation passed'); } catch (e) { - AppConfig.debugPrint('Environment validation failed: $e'); + debugPrint('❌ Environment validation failed: $e'); if (AppConfig.isProduction) { rethrow; } @@ -49,6 +50,9 @@ class _MyAppState extends State { bool _initialLinkHandled = false; late OAuthDeepLinkHandler _oauthHandler; + Uri? _lastHandledUri; + DateTime? _lastHandledTime; + @override void initState() { super.initState(); @@ -56,7 +60,6 @@ class _MyAppState extends State { _oauthHandler = OAuthDeepLinkHandler(); _oauthHandler.onOAuthComplete = _handleOAuthComplete; _initDeepLinkListener(); - _initOAuthHandler(); } @override @@ -66,10 +69,6 @@ class _MyAppState extends State { super.dispose(); } - Future _initOAuthHandler() async { - await _oauthHandler.initialize(); - } - void _handleOAuthComplete(String provider, bool success, String? message) { WidgetsBinding.instance.addPostFrameCallback((_) { final context = navigatorKey.currentContext; @@ -116,6 +115,22 @@ class _MyAppState extends State { void _handleDeepLink(Uri uri) { debugPrint('Handling deep link: $uri'); + final now = DateTime.now(); + if (_lastHandledUri?.toString() == uri.toString() && + _lastHandledTime != null && + now.difference(_lastHandledTime!).inSeconds < 2) { + debugPrint('Duplicate deep link ignored (same URI within 2 seconds)'); + return; + } + + _lastHandledUri = uri; + _lastHandledTime = now; + + if (_oauthHandler.isOAuthCallback(uri)) { + _oauthHandler.handleDeepLink(uri); + return; + } + if (uri.scheme == AppConfig.urlScheme && uri.host == AppConfig.resetPasswordDeepLink) { final token = uri.queryParameters['token']; @@ -146,6 +161,7 @@ class _MyAppState extends State { ChangeNotifierProvider(create: (_) => ServiceCatalogProvider()), ChangeNotifierProvider(create: (_) => ConnectedServicesProvider()), ChangeNotifierProvider(create: (_) => AutomationStatsProvider()), + ChangeNotifierProvider(create: (_) => CompatibilityProvider()), ], child: Builder( builder: (context) { @@ -167,6 +183,18 @@ class _MyAppState extends State { } }; + // Load compatibility rules when app starts + WidgetsBinding.instance.addPostFrameCallback((_) { + final compatibilityProvider = Provider.of( + context, + listen: false, + ); + if (!compatibilityProvider.isLoaded && + !compatibilityProvider.isLoading) { + compatibilityProvider.loadRules(); + } + }); + return MaterialApp( navigatorKey: navigatorKey, title: '${AppConfig.appName} Mobile', diff --git a/mobile/lib/models/applet.dart b/mobile/lib/models/applet.dart index 27fbb767..bf317fe9 100644 --- a/mobile/lib/models/applet.dart +++ b/mobile/lib/models/applet.dart @@ -56,16 +56,34 @@ class ActionData { // Handle both formats: full object or just ID if (json is Map) { try { + ServiceData serviceData; + if (json['service'] != null) { + serviceData = ServiceData.fromJson(json['service']); + } else if (json['service_id'] != null || json['service_name'] != null) { + serviceData = ServiceData( + id: json['service_id'] ?? 0, + name: json['service_name'] ?? 'Unknown Service', + description: 'Service loaded from backend', + status: 'active', + ); + } else { + serviceData = ServiceData( + id: 0, + name: 'Unknown Service', + description: 'Service loaded from backend', + status: 'active', + ); + } + return ActionData( id: json['id'], name: json['name'] ?? 'Unknown Action', description: json['description'] ?? 'Action loaded from backend', - service: ServiceData.fromJson(json['service']), + service: serviceData, ); - } catch (e, stackTrace) { - debugPrint('❌ ERROR parsing ActionData Map: $e'); - debugPrint('📊 Action JSON: $json'); - debugPrint('📚 Stack trace: $stackTrace'); + } catch (e) { + debugPrint('[APPLETS] ❌ Parse error: $e'); + debugPrint('[APPLETS] Action JSON: $json'); rethrow; } } else if (json is int) { @@ -83,7 +101,7 @@ class ActionData { ); } else { debugPrint( - '❌ ERROR: Invalid action data format: $json (type: ${json.runtimeType})', + '[APPLETS] ❌ Invalid action format: $json (type: ${json.runtimeType})', ); throw FormatException('Invalid action data format: $json'); } @@ -116,16 +134,34 @@ class ReactionData { // Handle both formats: full object or just ID if (json is Map) { try { + ServiceData serviceData; + if (json['service'] != null) { + serviceData = ServiceData.fromJson(json['service']); + } else if (json['service_id'] != null || json['service_name'] != null) { + serviceData = ServiceData( + id: json['service_id'] ?? 0, + name: json['service_name'] ?? 'Unknown Service', + description: 'Service loaded from backend', + status: 'active', + ); + } else { + serviceData = ServiceData( + id: 0, + name: 'Unknown Service', + description: 'Service loaded from backend', + status: 'active', + ); + } + return ReactionData( id: json['id'], name: json['name'] ?? 'Unknown Reaction', description: json['description'] ?? 'Reaction loaded from backend', - service: ServiceData.fromJson(json['service']), + service: serviceData, ); - } catch (e, stackTrace) { - debugPrint('❌ ERROR parsing ReactionData Map: $e'); - debugPrint('📊 Reaction JSON: $json'); - debugPrint('📚 Stack trace: $stackTrace'); + } catch (e) { + debugPrint('[APPLETS] ❌ Parse error: $e'); + debugPrint('[APPLETS] Reaction JSON: $json'); rethrow; } } else if (json is int) { @@ -183,11 +219,14 @@ class Applet { // Factory to create from JSON (backend format) factory Applet.fromJson(Map json) { try { + final actionData = json['action_detail'] ?? json['action']; + final reactionData = json['reaction_detail'] ?? json['reaction']; + return Applet( id: json['id'], name: json['name'] ?? '', - action: ActionData.fromJson(json['action']), - reaction: ReactionData.fromJson(json['reaction']), + action: ActionData.fromJson(actionData), + reaction: ReactionData.fromJson(reactionData), actionConfig: json['action_config'] ?? {}, reactionConfig: json['reaction_config'] ?? {}, status: json['status'] ?? 'active', @@ -196,8 +235,8 @@ class Applet { ), ); } catch (e, stackTrace) { - debugPrint('❌ ERROR in Applet.fromJson: $e'); - debugPrint('📊 JSON data: $json'); + debugPrint('[APPLETS] ❌ Parse error: $e'); + debugPrint('[APPLETS] JSON: $json'); debugPrint('📚 Stack trace: $stackTrace'); rethrow; } diff --git a/mobile/lib/models/service.dart b/mobile/lib/models/service.dart index a45d7f34..a2c1725c 100644 --- a/mobile/lib/models/service.dart +++ b/mobile/lib/models/service.dart @@ -1,15 +1,17 @@ -import '../config/service_provider_config.dart'; - /// Model representing a service from the about.json endpoint class Service { final int id; final String name; + final bool requiresOAuth; + final String? logo; final List actions; final List reactions; Service({ required this.id, required this.name, + required this.requiresOAuth, + this.logo, required this.actions, required this.reactions, }); @@ -18,6 +20,8 @@ class Service { return Service( id: json['id'] as int? ?? 0, name: (json['name'] as String?)?.trim() ?? '', + requiresOAuth: (json['requires_oauth'] as bool?) ?? false, + logo: (json['logo'] as String?), actions: (json['actions'] as List?) ?.map( @@ -41,6 +45,8 @@ class Service { return { 'id': id, 'name': name, + 'requires_oauth': requiresOAuth, + 'logo': logo, 'actions': actions.map((action) => action.toJson()).toList(), 'reactions': reactions.map((reaction) => reaction.toJson()).toList(), }; @@ -50,9 +56,7 @@ class Service { String get displayName { if (name.isEmpty) return 'Unknown Service'; - String mappedName = ServiceProviderConfig.mapServiceName(name); - - return mappedName + return name .split('_') .map((word) { if (word.isEmpty) return ''; diff --git a/mobile/lib/pages/create_applet_page.dart b/mobile/lib/pages/create_applet_page.dart index 707a3598..bae57afe 100644 --- a/mobile/lib/pages/create_applet_page.dart +++ b/mobile/lib/pages/create_applet_page.dart @@ -26,7 +26,8 @@ class _CreateAppletPageState extends State { int? _selectedActionId; int? _selectedReactionId; - Map _actionConfig = {}; + // Configuration for the trigger action (not the reaction action) + Map _triggerActionConfig = {}; Map _reactionConfig = {}; bool Function()? _validateConfigForm; @@ -153,6 +154,7 @@ class _CreateAppletPageState extends State { _selectedActionId = context .read() .getActionId(value!); + _triggerActionConfig = {}; }); }, ), @@ -165,6 +167,7 @@ class _CreateAppletPageState extends State { ActionConfigCard( selectedService: _selectedActionService, selectedReaction: _selectedActionReaction, + selectedTriggerAction: _selectedTriggerAction, onServiceChanged: (value) { setState(() { _selectedActionService = value; @@ -179,6 +182,7 @@ class _CreateAppletPageState extends State { _selectedReactionId = context .read() .getReactionId(value!); + _reactionConfig = {}; }); }, ), @@ -200,11 +204,11 @@ class _CreateAppletPageState extends State { .read() .getReaction(_selectedActionReaction!) ?.configSchema, - actionConfig: _actionConfig, + actionConfig: _triggerActionConfig, reactionConfig: _reactionConfig, onActionConfigChanged: (config) { setState(() { - _actionConfig = config; + _triggerActionConfig = config; }); }, onReactionConfigChanged: (config) { @@ -225,7 +229,7 @@ class _CreateAppletPageState extends State { AutomationPreviewCard( triggerServiceName: _selectedTriggerService, triggerActionName: _selectedTriggerAction, - actionConfig: _actionConfig, + actionConfig: _triggerActionConfig, reactionServiceName: _selectedActionService, reactionActionName: _selectedActionReaction, reactionConfig: _reactionConfig, @@ -305,7 +309,7 @@ class _CreateAppletPageState extends State { if (!_validateConfigSchema( schema: action?.configSchema, - config: _actionConfig, + config: _triggerActionConfig, )) { return; } @@ -322,7 +326,9 @@ class _CreateAppletPageState extends State { : 'Created from mobile app', actionId: _selectedActionId!, reactionId: _selectedReactionId!, - actionConfig: _actionConfig.isNotEmpty ? _actionConfig : {}, + actionConfig: _triggerActionConfig.isNotEmpty + ? _triggerActionConfig + : {}, reactionConfig: _reactionConfig.isNotEmpty ? _reactionConfig : {}, ); @@ -339,7 +345,7 @@ class _CreateAppletPageState extends State { 'Automation "${applet.name}" created successfully!', ); - // Reset form + // Reset form completely _nameController.clear(); setState(() { _selectedTriggerService = null; @@ -348,6 +354,8 @@ class _CreateAppletPageState extends State { _selectedActionReaction = null; _selectedActionId = null; _selectedReactionId = null; + _triggerActionConfig = {}; + _reactionConfig = {}; }); _formKey.currentState?.reset(); diff --git a/mobile/lib/pages/login_page.dart b/mobile/lib/pages/login_page.dart index d363f52a..a9feecb3 100644 --- a/mobile/lib/pages/login_page.dart +++ b/mobile/lib/pages/login_page.dart @@ -97,7 +97,13 @@ class _LoginPageState extends State { final success = await authProvider.loginWithGoogle(); - if (!mounted) return; + if (!mounted) { + debugPrint( + '🔐 [LoginPage] ⚠️ Widget unmounted before Google login response', + ); + return; + } + setState(() => _isGoogleLoading = false); if (success) { diff --git a/mobile/lib/pages/service_connections_page.dart b/mobile/lib/pages/service_connections_page.dart index 03ae885f..7fa35ed9 100644 --- a/mobile/lib/pages/service_connections_page.dart +++ b/mobile/lib/pages/service_connections_page.dart @@ -1,12 +1,24 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import '../models/service.dart'; import '../models/service_token.dart'; import '../providers/service_catalog_provider.dart'; import '../services/oauth_service.dart'; -import '../utils/service_icons.dart'; import '../config/service_provider_config.dart'; +import '../utils/service_icons.dart'; + +/// Get color filter for logos based on connection status +ColorFilter? _getLogoColorFilter(bool requiresOAuth, bool isConnected) { + if (!requiresOAuth) { + return ColorFilter.mode(Colors.green, BlendMode.srcIn); + } + if (isConnected) { + return ColorFilter.mode(Colors.green, BlendMode.srcIn); + } + return ColorFilter.mode(Colors.blue, BlendMode.srcIn); +} class ServiceConnectionsPage extends StatefulWidget { const ServiceConnectionsPage({super.key}); @@ -110,9 +122,13 @@ class _ServiceConnectionsPageState extends State { try { final services = await _oauthService.getConnectedServices(); + final mappedServiceName = ServiceProviderConfig.mapServiceName( + serviceName, + ); final isConnected = services.connectedServices.any( (token) => - token.serviceName.toLowerCase() == serviceName.toLowerCase(), + token.serviceName.toLowerCase() == + mappedServiceName.toLowerCase(), ); if (isConnected) { @@ -245,11 +261,8 @@ class _ServiceConnectionsPageState extends State { final sortedServices = [...allServices] ..sort((a, b) { - final aRequiresOAuth = ServiceProviderConfig.requiresOAuth(a.name); - final bRequiresOAuth = ServiceProviderConfig.requiresOAuth(b.name); - - if (aRequiresOAuth && !bRequiresOAuth) return -1; - if (!aRequiresOAuth && bRequiresOAuth) return 1; + if (a.requiresOAuth && !b.requiresOAuth) return -1; + if (!a.requiresOAuth && b.requiresOAuth) return 1; return a.displayName.compareTo(b.displayName); }); @@ -268,10 +281,7 @@ class _ServiceConnectionsPageState extends State { ), const SizedBox(height: 12), ...sortedServices - .where( - (service) => - ServiceProviderConfig.requiresOAuth(service.name), - ) + .where((service) => service.requiresOAuth) .map((service) => _buildServiceCard(service)), const SizedBox(height: 24), @@ -286,10 +296,7 @@ class _ServiceConnectionsPageState extends State { ), const SizedBox(height: 12), ...sortedServices - .where( - (service) => - !ServiceProviderConfig.requiresOAuth(service.name), - ) + .where((service) => !service.requiresOAuth) .map((service) => _buildServiceCard(service)), ], ), @@ -299,7 +306,7 @@ class _ServiceConnectionsPageState extends State { } Widget _buildServiceCard(Service service) { - final requiresOAuth = ServiceProviderConfig.requiresOAuth(service.name); + final requiresOAuth = service.requiresOAuth; final isConnected = _isServiceConnected(service.name); return Card( @@ -311,20 +318,29 @@ class _ServiceConnectionsPageState extends State { ? Colors.green.withValues(alpha: 0.2) : Colors.blue.withValues(alpha: 0.2)) : Colors.green.withValues(alpha: 0.2), - child: Image.network( - ServiceProviderConfig.getIconUrl(service.name), - width: 24, - height: 24, - errorBuilder: (context, error, stackTrace) { - return Icon( - ServiceIcons.getServiceIcon(service.name), - color: requiresOAuth - ? (isConnected ? Colors.green : Colors.blue) - : Colors.green, - size: 20, - ); - }, - ), + child: service.logo != null + ? SvgPicture.network( + service.logo!, + width: 24, + height: 24, + placeholderBuilder: (context) => + const CircularProgressIndicator(strokeWidth: 1), + errorBuilder: (context, error, stackTrace) => Icon( + ServiceIcons.getServiceIcon(service.name), + color: requiresOAuth + ? (isConnected ? Colors.green : Colors.blue) + : Colors.green, + size: 20, + ), + colorFilter: _getLogoColorFilter(requiresOAuth, isConnected), + ) + : Icon( + ServiceIcons.getServiceIcon(service.name), + color: requiresOAuth + ? (isConnected ? Colors.green : Colors.blue) + : Colors.green, + size: 20, + ), ), title: Row( children: [ diff --git a/mobile/lib/providers/applet_provider.dart b/mobile/lib/providers/applet_provider.dart index 10f47c29..ebb2e7dd 100644 --- a/mobile/lib/providers/applet_provider.dart +++ b/mobile/lib/providers/applet_provider.dart @@ -42,12 +42,12 @@ class AppletProvider extends ChangeNotifier { } void _enrichApplets() { - debugPrint('📊 Applets loaded: ${_applets.length} applets'); + debugPrint('[APPLETS] 📊 Loaded: ${_applets.length} applets'); for (final applet in _applets) { if (applet.action.name.contains('Unknown') || applet.action.service.name.contains('Unknown')) { debugPrint( - '⚠️ Applet "${applet.name}" has unknown action/service data - needs enrichment', + '[APPLETS] ⚠️ Unknown action/service - needs enrichment', ); } } diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart index 57017992..a1a61908 100644 --- a/mobile/lib/providers/auth_provider.dart +++ b/mobile/lib/providers/auth_provider.dart @@ -105,7 +105,7 @@ class AuthProvider extends ChangeNotifier { if (data != null) { _isAuthenticated = true; - _userEmail = data['email']; + _userEmail = data['email'] ?? data['user']?['email']; _setLoading(false); return true; } diff --git a/mobile/lib/providers/compatibility_provider.dart b/mobile/lib/providers/compatibility_provider.dart new file mode 100644 index 00000000..4ce5626c --- /dev/null +++ b/mobile/lib/providers/compatibility_provider.dart @@ -0,0 +1,112 @@ +import 'package:flutter/foundation.dart'; +import 'dart:convert'; +import '../services/http_client_service.dart'; +import '../config/api_config.dart'; + +/// Provider for managing action-reaction compatibility rules +/// Loads rules dynamically from the backend on first use and caches them +class CompatibilityProvider extends ChangeNotifier { + Map> _rules = {}; + bool _isLoaded = false; + bool _isLoading = false; + String? _error; + + final HttpClientService _httpClient = HttpClientService(); + + /// Get all compatibility rules + Map> get rules => _rules; + + /// Check if rules are loaded + bool get isLoaded => _isLoaded; + + /// Check if rules are currently loading + bool get isLoading => _isLoading; + + /// Get any error message + String? get error => _error; + + /// Initialize and load rules from backend (can be called multiple times safely) + Future loadRules() async { + if (_isLoaded) { + return; + } + + if (_isLoading) { + return; + } + + _isLoading = true; + _error = null; + notifyListeners(); + + try { + final response = await _httpClient.get( + '${ApiConfig.baseUrl}/api/compatibility-rules/', + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as Map; + + _rules = {}; + data.forEach((key, value) { + if (value is List) { + _rules[key] = List.from(value); + } + }); + + _isLoaded = true; + } else { + throw Exception( + 'Failed to load compatibility rules: ${response.statusCode}', + ); + } + } catch (e) { + _error = 'Failed to load compatibility rules: $e'; + debugPrint('[CompatibilityProvider] Error: $_error'); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// Get compatible reactions for an action + List getCompatibleReactions( + String actionName, + List allReactions, + ) { + if (!_isLoaded) { + return allReactions; + } + + final compatible = _rules[actionName] ?? []; + + // If "*" is in the rules, return all reactions + if (compatible.contains("*")) { + return allReactions; + } + + // Otherwise filter to only compatible ones + return allReactions + .where((reaction) => compatible.contains(reaction)) + .toList(); + } + + /// Check if a reaction is compatible with an action + bool isCompatible(String actionName, String reactionName) { + if (!_isLoaded) { + return true; + } + + final compatible = _rules[actionName] ?? []; + if (compatible.contains("*")) return true; + return compatible.contains(reactionName); + } + + /// Refresh rules from backend + Future refreshRules() async { + _isLoaded = false; + _rules = {}; + _error = null; + await loadRules(); + } +} diff --git a/mobile/lib/providers/connected_services_provider.dart b/mobile/lib/providers/connected_services_provider.dart index 1c074915..26f8c95b 100644 --- a/mobile/lib/providers/connected_services_provider.dart +++ b/mobile/lib/providers/connected_services_provider.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../services/oauth_service.dart'; import '../models/service_token.dart'; +import '../utils/service_token_mapper.dart'; /// Provider for managing connected OAuth services class ConnectedServicesProvider extends ChangeNotifier { @@ -17,9 +18,11 @@ class ConnectedServicesProvider extends ChangeNotifier { String? get error => _error; /// Check if a specific service is connected + /// Maps service names (e.g., gmail -> google) to find the actual token bool isServiceConnected(String serviceName) { + final tokenName = ServiceTokenMapper.resolveTokenService(serviceName); return _connectedServices.any( - (s) => s.serviceName.toLowerCase() == serviceName.toLowerCase(), + (s) => s.serviceName.toLowerCase() == tokenName.toLowerCase(), ); } diff --git a/mobile/lib/services/applet_service.dart b/mobile/lib/services/applet_service.dart index 6e2b2f9f..248b1278 100644 --- a/mobile/lib/services/applet_service.dart +++ b/mobile/lib/services/applet_service.dart @@ -67,15 +67,12 @@ class AppletService { final applet = _httpClient.parseResponse(response, (data) { try { return Applet.fromJson(data); - } catch (e, stackTrace) { - debugPrint('❌ ERROR parsing Applet from JSON: $e'); - debugPrint('📊 Raw data: $data'); - debugPrint('📚 Stack trace: $stackTrace'); + } catch (e) { + debugPrint('[APPLETS] ❌ Parse error: $e'); rethrow; } }); - // Clear cache to force refresh _cache.remove(_cacheKeyPrefix); return applet; } diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart index eea86a22..70137a4c 100644 --- a/mobile/lib/services/auth_service.dart +++ b/mobile/lib/services/auth_service.dart @@ -108,38 +108,25 @@ class AuthService { // GOOGLE AUTHENTICATION // ============================================ - /// Enable debug logs for Google Sign-In (set to false in production) - static const bool _enableGoogleSignInDebugLogs = true; - - void _logDebug(String message) { - if (_enableGoogleSignInDebugLogs && kDebugMode) { - debugPrint(message); - } - } - Future _ensureGoogleSignInInitialized() async { if (!_isGoogleSignInInitialized) { - _logDebug('🔧 Initializing Google Sign-In...'); - - await _googleSignIn.initialize(); - - _isGoogleSignInInitialized = true; - _logDebug('✅ Google Sign-In initialized successfully'); + try { + await _googleSignIn.initialize(); + _isGoogleSignInInitialized = true; + } catch (e) { + debugPrint('[OAUTH] ❌ Init failed: $e'); + rethrow; + } } } /// Returns user data with tokens if successful, null otherwise Future?> loginWithGoogle() async { try { - _logDebug('🚀 Starting Google Sign-In...'); await _ensureGoogleSignInInitialized(); - _logDebug('🔐 Authenticating with Google...'); final GoogleSignInAccount account = await _googleSignIn.authenticate(); - _logDebug('✅ Google authentication successful'); - _logDebug('📧 Account email: ${account.email}'); - final GoogleSignInAuthentication auth = account.authentication; final String? idToken = auth.idToken; @@ -147,27 +134,43 @@ class AuthService { throw AuthException('No Google ID token received'); } - _logDebug('🎫 ID Token received, sending to backend...'); - final response = await http.post( - Uri.parse(ApiConfig.googleLoginUrl), - headers: {'Content-Type': 'application/json'}, - body: json.encode({'id_token': idToken}), - ); + final endpoint = ApiConfig.googleLoginUrl; + + final stopwatch = Stopwatch()..start(); + final response = await http + .post( + Uri.parse(endpoint), + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'AREA-Mobile/1.0', + }, + body: json.encode({'id_token': idToken}), + ) + .timeout( + const Duration(seconds: 30), + onTimeout: () { + throw AuthException('Backend timeout'); + }, + ); + stopwatch.stop(); if (response.statusCode == 200) { - final data = json.decode(response.body); - await _storeTokensFromResponse(data); - _logDebug('✅ Login successful!'); - return data; + try { + final data = json.decode(response.body); + await _storeTokensFromResponse(data); + debugPrint('[OAUTH] ✅ Login successful!'); + return data; + } catch (e) { + debugPrint('[OAUTH] ❌ Parse error: $e'); + rethrow; + } + } else { + debugPrint('[OAUTH] ❌ Backend error: ${response.statusCode}'); + final errorMessage = _parseErrorResponse(response); + throw AuthException(errorMessage, statusCode: response.statusCode); } - - final errorMessage = _parseErrorResponse(response); - _logDebug('❌ Backend error: $errorMessage'); - throw AuthException(errorMessage, statusCode: response.statusCode); - } catch (e, stackTrace) { - _logDebug('❌ Google sign-in error: $e'); - _logDebug('📍 Error type: ${e.runtimeType}'); - _logDebug('📚 Stack trace: $stackTrace'); + } catch (e) { + debugPrint('[OAUTH] ❌ Error: $e'); if (e is AuthException) rethrow; throw AuthException('Google sign-in error: ${e.toString()}'); } diff --git a/mobile/lib/services/http_client_service.dart b/mobile/lib/services/http_client_service.dart index a07c3dab..c7d052fa 100644 --- a/mobile/lib/services/http_client_service.dart +++ b/mobile/lib/services/http_client_service.dart @@ -97,7 +97,6 @@ class HttpClientService { String url, { Map? additionalHeaders, }) async { - debugPrint('[HTTP-CLIENT] GET: $url'); return await _retryRequest(() async { final headers = await _getHeaders(); if (additionalHeaders != null) { @@ -113,17 +112,13 @@ class HttpClientService { Map? body, Map? additionalHeaders, }) async { - debugPrint('[HTTP-CLIENT] POST: $url - Body: ${json.encode(body)}'); + final bodyJson = body != null ? json.encode(body) : null; return await _retryRequest(() async { final headers = await _getHeaders(); if (additionalHeaders != null) { headers.addAll(additionalHeaders); } - return http.post( - Uri.parse(url), - headers: headers, - body: body != null ? json.encode(body) : null, - ); + return http.post(Uri.parse(url), headers: headers, body: bodyJson); }); } diff --git a/mobile/lib/services/oauth_service.dart b/mobile/lib/services/oauth_service.dart index 11b922cd..201b384a 100644 --- a/mobile/lib/services/oauth_service.dart +++ b/mobile/lib/services/oauth_service.dart @@ -1,11 +1,13 @@ import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import '../config/api_config.dart'; import '../models/service_token.dart'; import '../models/connection_history.dart'; +import '../utils/service_token_mapper.dart'; import 'token_service.dart'; -/// Custom exception for OAuth errors class OAuthException implements Exception { final String message; final int? statusCode; @@ -16,21 +18,13 @@ class OAuthException implements Exception { String toString() => message; } -/// Service responsible for OAuth2 operations class OAuthService { final TokenService _tokenService = TokenService(); - // Singleton pattern static final OAuthService _instance = OAuthService._internal(); factory OAuthService() => _instance; OAuthService._internal(); - // ============================================ - // OAUTH2 INITIATION - // ============================================ - - /// Initiate OAuth2 flow for a provider - /// Returns the authorization URL to open in browser Future initiateOAuth(String provider) async { try { final token = await _tokenService.getAuthToken(); @@ -59,11 +53,6 @@ class OAuthService { } } - // ============================================ - // OAUTH2 CALLBACK HANDLING - // ============================================ - - /// Handle OAuth2 callback with code and state Future> handleOAuthCallback({ required String provider, required String code, @@ -75,33 +64,59 @@ class OAuthService { throw OAuthException('User not authenticated'); } - final response = await http.get( - Uri.parse( - ApiConfig.oauthCallbackUrl(provider, code: code, state: state), - ), - headers: { - 'Authorization': 'Bearer $token', - 'Content-Type': 'application/json', - }, + final callbackUrl = ApiConfig.oauthCallbackUrl( + provider, + code: code, + state: state, ); + debugPrint('[OAUTH] Making callback request to: $callbackUrl'); + + try { + final response = await http + .get( + Uri.parse(callbackUrl), + headers: { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + }, + ) + .timeout(const Duration(seconds: 15)); + + debugPrint('[OAUTH] Got response status: ${response.statusCode}'); + + if (response.statusCode == 302 || response.statusCode == 301) { + debugPrint('[OAUTH] Got redirect (302/301) - treating as success'); + return { + 'message': 'Successfully connected to $provider', + 'provider': provider, + }; + } - if (response.statusCode == 200) { - return json.decode(response.body); - } + if (response.statusCode == 200) { + return json.decode(response.body); + } - final errorMessage = _parseErrorResponse(response); - throw OAuthException(errorMessage, statusCode: response.statusCode); + final errorMessage = _parseErrorResponse(response); + throw OAuthException(errorMessage, statusCode: response.statusCode); + } on SocketException catch (e) { + if (e.message.contains('Connection refused') || + e.message.contains('localhost')) { + debugPrint( + '[OAUTH] Got connection refused (expected from redirect attempt) - treating as success', + ); + return { + 'message': 'Successfully connected to $provider', + 'provider': provider, + }; + } + rethrow; + } } catch (e) { if (e is OAuthException) rethrow; throw OAuthException('Failed to complete OAuth: ${e.toString()}'); } } - // ============================================ - // SERVICE MANAGEMENT - // ============================================ - - /// Get list of connected services Future getConnectedServices() async { try { final token = await _tokenService.getAuthToken(); @@ -130,7 +145,6 @@ class OAuthService { } } - /// Get connection history for troubleshooting Future getConnectionHistory({int limit = 20}) async { try { final token = await _tokenService.getAuthToken(); @@ -161,7 +175,6 @@ class OAuthService { } } - /// Disconnect a service Future> disconnectService(String provider) async { try { final token = await _tokenService.getAuthToken(); @@ -189,24 +202,24 @@ class OAuthService { } } - /// Check if a specific service is connected Future isServiceConnected(String provider) async { try { final services = await getConnectedServices(); + final tokenName = ServiceTokenMapper.resolveTokenService(provider); return services.connectedServices.any( - (s) => s.serviceName.toLowerCase() == provider.toLowerCase(), + (s) => s.serviceName.toLowerCase() == tokenName.toLowerCase(), ); } catch (e) { return false; } } - /// Get token info for a specific service Future getServiceToken(String provider) async { try { final services = await getConnectedServices(); + final tokenName = ServiceTokenMapper.resolveTokenService(provider); final matchingServices = services.connectedServices.where( - (s) => s.serviceName.toLowerCase() == provider.toLowerCase(), + (s) => s.serviceName.toLowerCase() == tokenName.toLowerCase(), ); return matchingServices.isNotEmpty ? matchingServices.first : null; } catch (e) { @@ -214,17 +227,11 @@ class OAuthService { } } - // ============================================ - // HELPER METHODS - // ============================================ - - /// Parse error response from API String _parseErrorResponse(http.Response response) { try { final data = json.decode(response.body); if (data is Map) { - // Try to get error message from various possible fields if (data.containsKey('message')) { return data['message'] as String; } @@ -239,7 +246,6 @@ class OAuthService { return data['detail'] as String; } - // If it's a map of field errors, combine them final errors = []; data.forEach((key, value) { if (value is List) { diff --git a/mobile/lib/services/service_catalog_service.dart b/mobile/lib/services/service_catalog_service.dart index b13dbeb2..e0b90b84 100644 --- a/mobile/lib/services/service_catalog_service.dart +++ b/mobile/lib/services/service_catalog_service.dart @@ -102,6 +102,8 @@ class ServiceCatalogService { return Service( id: serviceId, name: serviceName, + requiresOAuth: (serviceJson['requires_oauth'] as bool?) ?? false, + logo: (serviceJson['logo'] as String?), actions: actionsByService[serviceId] ?? [], reactions: reactionsByService[serviceId] ?? [], ); diff --git a/mobile/lib/utils/debug_helper.dart b/mobile/lib/utils/debug_helper.dart index ad9eb687..6045a8a5 100644 --- a/mobile/lib/utils/debug_helper.dart +++ b/mobile/lib/utils/debug_helper.dart @@ -5,29 +5,36 @@ import '../config/api_config.dart'; class DebugHelper { static void printConfiguration() { if (kDebugMode) { - print('\n=== AREA App Debug Info ==='); - print('Platform: ${Platform.operatingSystem}'); - print('Base URL: ${ApiConfig.baseUrl}'); - print('Login URL: ${ApiConfig.loginUrl}'); - print('Register URL: ${ApiConfig.registerUrl}'); - print('Profile URL: ${ApiConfig.profileUrl}'); - print('Is Android: ${Platform.isAndroid}'); - print('Is iOS: ${Platform.isIOS}'); - print('Debug mode: ${ApiConfig.isDebug}'); - print('Timeout: ${ApiConfig.timeout.inSeconds}s'); - print('Max retries: ${ApiConfig.maxRetries}'); - print('===========================\n'); + print('\n═════════════════════════════════════════════════════════'); + print('📱 AREA APP CONFIGURATION'); + print('═════════════════════════════════════════════════════════'); + print('🖥️ Platform: ${Platform.operatingSystem}'); + print('🌐 Base URL: ${ApiConfig.baseUrl}'); + print('🔐 Auth Base URL: ${ApiConfig.authBaseUrl}'); + print('📡 API Base URL: ${ApiConfig.apiBaseUrl}'); + print('🔑 Google Login URL: ${ApiConfig.googleLoginUrl}'); + print('👤 Login URL: ${ApiConfig.loginUrl}'); + print('📝 Register URL: ${ApiConfig.registerUrl}'); + print('👥 Profile URL: ${ApiConfig.profileUrl}'); + print('📱 Is Android: ${Platform.isAndroid}'); + print('🍎 Is iOS: ${Platform.isIOS}'); + print('🔍 Debug mode: ${ApiConfig.isDebug}'); + print('⏱️ Timeout: ${ApiConfig.timeout.inSeconds}s'); + print('🔄 Max retries: ${ApiConfig.maxRetries}'); + print('═════════════════════════════════════════════════════════\n'); } } static void printAuthStatus(bool isAuthenticated, String? error) { if (kDebugMode) { - print('\n=== Auth Status ==='); - print('Authenticated: $isAuthenticated'); + print('\n═════════════════════════════════════════════════════════'); + print('🔐 AUTH STATUS'); + print('═════════════════════════════════════════════════════════'); + print('Authenticated: ${isAuthenticated ? "✅ YES" : "❌ NO"}'); if (error != null) { print('Error: $error'); } - print('==================\n'); + print('═════════════════════════════════════════════════════════\n'); } } @@ -39,20 +46,22 @@ class DebugHelper { String? response, }) { if (kDebugMode) { - print('\n=== API Call ==='); + print('\n═════════════════════════════════════════════════════════'); + print('📡 API CALL'); + print('═════════════════════════════════════════════════════════'); print('$method $url'); if (body != null) { - print('Body: $body'); + print('📦 Body: $body'); } if (statusCode != null) { - print('Status: $statusCode'); + print('📊 Status: $statusCode'); } if (response != null) { print( - 'Response: ${response.length > 200 ? '${response.substring(0, 200)}...' : response}', + '📋 Response: ${response.length > 200 ? '${response.substring(0, 200)}...' : response}', ); } - print('================\n'); + print('═════════════════════════════════════════════════════════\n'); } } } diff --git a/mobile/lib/utils/oauth_deep_link_handler.dart b/mobile/lib/utils/oauth_deep_link_handler.dart index 30be8151..0c70b213 100644 --- a/mobile/lib/utils/oauth_deep_link_handler.dart +++ b/mobile/lib/utils/oauth_deep_link_handler.dart @@ -1,105 +1,146 @@ import 'dart:async'; -import 'package:app_links/app_links.dart'; import 'package:flutter/foundation.dart'; import '../services/oauth_service.dart'; import '../config/app_config.dart'; -/// Handler for OAuth2 deep link callbacks class OAuthDeepLinkHandler { final OAuthService _oauthService = OAuthService(); - final _appLinks = AppLinks(); + final Set _processedStates = {}; - StreamSubscription? _linkSubscription; - - // Callback when OAuth is completed Function(String provider, bool success, String? message)? onOAuthComplete; - // Singleton pattern static final OAuthDeepLinkHandler _instance = OAuthDeepLinkHandler._internal(); factory OAuthDeepLinkHandler() => _instance; OAuthDeepLinkHandler._internal(); - /// Initialize deep link listening - Future initialize() async { - // Handle initial link if app was opened from a link - try { - final initialUri = await _appLinks.getInitialLink(); - if (initialUri != null) { - await _handleDeepLink(initialUri); - } - } catch (e) { - debugPrint('Error handling initial link: $e'); + bool isOAuthCallback(Uri uri) { + if (uri.scheme == AppConfig.urlScheme && uri.host == AppConfig.authPrefix) { + final pathSegments = uri.pathSegments; + return pathSegments.length >= 3 && + pathSegments[0] == AppConfig.oauthPath && + pathSegments[2] == AppConfig.callbackPath; + } else if (uri.scheme == 'http' || uri.scheme == 'https') { + final path = uri.path; + final pathSegments = path.split('/').where((s) => s.isNotEmpty).toList(); + return pathSegments.length >= 4 && + pathSegments[0] == 'auth' && + pathSegments[1] == 'oauth' && + pathSegments[3] == 'callback'; } + return false; + } - // Handle links while app is running - _linkSubscription = _appLinks.uriLinkStream.listen( - (uri) => _handleDeepLink(uri), - onError: (err) { - debugPrint('Deep link error: $err'); - }, - ); + Future handleDeepLink(Uri uri) async { + if (!isOAuthCallback(uri)) { + return; + } + await _handleDeepLink(uri); } - /// Handle OAuth deep link callback - Future _handleDeepLink(Uri uri) async { - debugPrint('Handling deep link: $uri'); + Map? _parseOAuthCallback(Uri uri) { + String? provider; + final code = uri.queryParameters['code']; + final state = uri.queryParameters['state']; + final error = uri.queryParameters['error']; + final errorDescription = uri.queryParameters['error_description']; if (uri.scheme == AppConfig.urlScheme && uri.host == AppConfig.authPrefix) { final pathSegments = uri.pathSegments; - if (pathSegments.length >= 3 && pathSegments[0] == AppConfig.oauthPath && pathSegments[2] == AppConfig.callbackPath) { - final provider = pathSegments[1]; - final code = uri.queryParameters['code']; - final state = uri.queryParameters['state']; - final error = uri.queryParameters['error']; - - if (error != null) { - // OAuth error - final errorDescription = - uri.queryParameters['error_description'] ?? error; - debugPrint('OAuth error: $errorDescription'); - onOAuthComplete?.call(provider, false, errorDescription); - return; - } - - if (code == null || state == null) { - debugPrint('Missing code or state in OAuth callback'); - onOAuthComplete?.call( - provider, - false, - 'Invalid OAuth callback parameters', - ); - return; - } - - // Complete OAuth flow - try { - final result = await _oauthService.handleOAuthCallback( - provider: provider, - code: code, - state: state, - ); - - final message = - result['message'] as String? ?? - 'Successfully connected to $provider'; - - debugPrint('OAuth success: $message'); - onOAuthComplete?.call(provider, true, message); - } catch (e) { - debugPrint('OAuth callback error: $e'); - onOAuthComplete?.call(provider, false, e.toString()); - } + provider = pathSegments[1]; + } + } else if (uri.scheme == 'http' || uri.scheme == 'https') { + final path = uri.path; + final pathSegments = path.split('/').where((s) => s.isNotEmpty).toList(); + + if (pathSegments.length >= 4 && + pathSegments[0] == 'auth' && + pathSegments[1] == 'oauth' && + pathSegments[3] == 'callback') { + provider = pathSegments[2]; } } + + if (provider == null) { + debugPrint('Could not parse OAuth provider from URI: $uri'); + return null; + } + + return { + 'provider': provider, + 'code': code, + 'state': state, + 'error': error, + 'errorDescription': errorDescription, + }; } - /// Dispose the handler - void dispose() { - _linkSubscription?.cancel(); - _linkSubscription = null; + Future _handleDeepLink(Uri uri) async { + debugPrint('[OAUTH-HANDLER] Handling deep link: $uri'); + + final oauthData = _parseOAuthCallback(uri); + if (oauthData == null) { + debugPrint('[OAUTH-HANDLER] Failed to parse OAuth data from URI'); + return; + } + + final provider = oauthData['provider'] as String; + final code = oauthData['code'] as String?; + final state = oauthData['state'] as String?; + final error = oauthData['error'] as String?; + final errorDescription = oauthData['errorDescription'] as String?; + + debugPrint( + '[OAUTH-HANDLER] Parsed: provider=$provider, code=${code?.substring(0, 10)}..., state=$state', + ); + + if (state != null && _processedStates.contains(state)) { + debugPrint('[OAUTH-HANDLER] OAuth state already processed: $state'); + return; + } + + if (state != null) { + _processedStates.add(state); + } + + if (error != null) { + final description = errorDescription ?? error; + debugPrint('OAuth error: $description'); + onOAuthComplete?.call(provider, false, description); + return; + } + + if (code == null || state == null) { + debugPrint('Missing code or state in OAuth callback'); + onOAuthComplete?.call( + provider, + false, + 'Invalid OAuth callback parameters', + ); + return; + } + + try { + debugPrint('[OAUTH-HANDLER] Calling backend to complete OAuth flow...'); + final result = await _oauthService.handleOAuthCallback( + provider: provider, + code: code, + state: state, + ); + + final message = + result['message'] as String? ?? 'Successfully connected to $provider'; + + debugPrint('[OAUTH-HANDLER] OAuth success: $message'); + onOAuthComplete?.call(provider, true, message); + } catch (e) { + debugPrint('[OAUTH-HANDLER] OAuth callback error: $e'); + onOAuthComplete?.call(provider, false, e.toString()); + } } + + void dispose() {} } diff --git a/mobile/lib/utils/service_token_mapper.dart b/mobile/lib/utils/service_token_mapper.dart new file mode 100644 index 00000000..cc0c315e --- /dev/null +++ b/mobile/lib/utils/service_token_mapper.dart @@ -0,0 +1,35 @@ +/// Maps service names to their OAuth provider token +/// +/// Problem: about.json lists "gmail" and "google_calendar" as separate services, +/// but OAuth only creates a "google" token. +/// +/// This mapper handles the translation so automation execution finds the right token. +class ServiceTokenMapper { + static const Map _tokenMap = { + 'gmail': 'google', + 'google_calendar': 'google', + }; + + /// Get the actual token service name to query in the database + /// + /// Example: + /// - resolveTokenService('gmail') → 'google' + /// - resolveTokenService('google') → 'google' + /// - resolveTokenService('github') → 'github' + static String resolveTokenService(String serviceName) { + return _tokenMap[serviceName] ?? serviceName; + } + + /// Check if a service uses a mapped token + static bool isMappedService(String serviceName) { + return _tokenMap.containsKey(serviceName); + } + + /// Get all services that map to a specific token + static List getServicesForToken(String tokenName) { + return _tokenMap.entries + .where((e) => e.value == tokenName) + .map((e) => e.key) + .toList(); + } +} diff --git a/mobile/lib/widgets/create_applet/action_config_card.dart b/mobile/lib/widgets/create_applet/action_config_card.dart index 4dd9c572..eb4d0d02 100644 --- a/mobile/lib/widgets/create_applet/action_config_card.dart +++ b/mobile/lib/widgets/create_applet/action_config_card.dart @@ -4,6 +4,7 @@ import 'service_action_selector_card.dart'; class ActionConfigCard extends StatelessWidget { final String? selectedService; final String? selectedReaction; + final String? selectedTriggerAction; // For reaction filtering final ValueChanged onServiceChanged; final ValueChanged onReactionChanged; @@ -13,6 +14,7 @@ class ActionConfigCard extends StatelessWidget { required this.selectedReaction, required this.onServiceChanged, required this.onReactionChanged, + this.selectedTriggerAction, }); @override @@ -23,6 +25,7 @@ class ActionConfigCard extends StatelessWidget { selectorType: SelectorType.action, selectedService: selectedService, selectedAction: selectedReaction, + selectedTriggerAction: selectedTriggerAction, onServiceChanged: onServiceChanged, onActionChanged: onReactionChanged, ); diff --git a/mobile/lib/widgets/create_applet/service_action_selector_card.dart b/mobile/lib/widgets/create_applet/service_action_selector_card.dart index adb95a77..59d60f40 100644 --- a/mobile/lib/widgets/create_applet/service_action_selector_card.dart +++ b/mobile/lib/widgets/create_applet/service_action_selector_card.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../providers/service_catalog_provider.dart'; import '../../providers/connected_services_provider.dart'; +import '../../providers/compatibility_provider.dart'; import '../../utils/service_icons.dart'; -import '../../config/service_provider_config.dart'; enum SelectorType { trigger, action } @@ -14,6 +14,8 @@ class ServiceActionSelectorCard extends StatefulWidget { final SelectorType selectorType; final String? selectedService; final String? selectedAction; + final String? + selectedTriggerAction; // For filtering reactions by compatibility final ValueChanged onServiceChanged; final ValueChanged onActionChanged; final bool filterByConnectedServices; @@ -28,6 +30,7 @@ class ServiceActionSelectorCard extends StatefulWidget { required this.onServiceChanged, required this.onActionChanged, this.filterByConnectedServices = true, + this.selectedTriggerAction, // Optional: for reaction filtering }); @override @@ -38,265 +41,310 @@ class ServiceActionSelectorCard extends StatefulWidget { class _ServiceActionSelectorCardState extends State { bool _showOnlyConnected = true; + /// Filter reactions based on selected trigger action compatibility + List>? _getFilteredReactions( + ServiceCatalogProvider serviceProvider, + CompatibilityProvider compatibilityProvider, + ) { + final allReactions = serviceProvider.getReactionsForService( + widget.selectedService!, + ); + + if (allReactions == null || allReactions.isEmpty) { + return null; + } + + // If we have a selected trigger action, filter by compatibility + if (widget.selectedTriggerAction != null && + widget.selectedTriggerAction!.isNotEmpty) { + final compatibleReactionNames = compatibilityProvider + .getCompatibleReactions( + widget.selectedTriggerAction!, + allReactions.map((r) => r.name).toList(), + ); + + final filteredReactions = allReactions + .where((reaction) => compatibleReactionNames.contains(reaction.name)) + .toList(); + + return filteredReactions.map((reaction) { + return DropdownMenuItem( + value: reaction.name, + child: Text(reaction.displayName), + ); + }).toList(); + } + + // No filtering if no trigger action selected + return allReactions.map((reaction) { + return DropdownMenuItem( + value: reaction.name, + child: Text(reaction.displayName), + ); + }).toList(); + } + @override Widget build(BuildContext context) { - return Consumer2( - builder: (context, serviceProvider, connectedProvider, child) { - // Filter services based on selector type - var filteredServices = serviceProvider.services - .where( - (service) => widget.selectorType == SelectorType.trigger - ? service.actions.isNotEmpty - : service.reactions.isNotEmpty, - ) - .toList(); + return Consumer3< + ServiceCatalogProvider, + ConnectedServicesProvider, + CompatibilityProvider + >( + builder: + ( + context, + serviceProvider, + connectedProvider, + compatibilityProvider, + child, + ) { + // Filter services based on selector type + var filteredServices = serviceProvider.services + .where( + (service) => widget.selectorType == SelectorType.trigger + ? service.actions.isNotEmpty + : service.reactions.isNotEmpty, + ) + .toList(); - // Further filter by connected services if enabled - if (widget.filterByConnectedServices && _showOnlyConnected) { - filteredServices = filteredServices.where((service) { - // Services that don't require OAuth are always available - if (!ServiceProviderConfig.requiresOAuth(service.name)) { - return true; + // Further filter by connected services if enabled + if (widget.filterByConnectedServices && _showOnlyConnected) { + filteredServices = filteredServices.where((service) { + // Services that don't require OAuth are always available + if (!service.requiresOAuth) { + return true; + } + // OAuth services must be connected + return connectedProvider.isServiceConnected(service.name); + }).toList(); } - // OAuth services must be connected - return connectedProvider.isServiceConnected(service.name); - }).toList(); - } - // Get labels and icons based on selector type - final serviceLabel = widget.selectorType == SelectorType.trigger - ? 'Trigger Service' - : 'Action Service'; - final serviceHint = widget.selectorType == SelectorType.trigger - ? 'Choose the triggering service' - : 'Choose the executing service'; - final serviceHelperText = widget.selectorType == SelectorType.trigger - ? 'Select the service that will trigger your automation' - : 'Select the service that will execute the action'; - final serviceIcon = widget.selectorType == SelectorType.trigger - ? Icons.sensors - : Icons.call_to_action; + // Get labels and icons based on selector type + final serviceLabel = widget.selectorType == SelectorType.trigger + ? 'Trigger Service' + : 'Action Service'; + final serviceHint = widget.selectorType == SelectorType.trigger + ? 'Choose the triggering service' + : 'Choose the executing service'; + final serviceHelperText = + widget.selectorType == SelectorType.trigger + ? 'Select the service that will trigger your automation' + : 'Select the service that will execute the action'; + final serviceIcon = widget.selectorType == SelectorType.trigger + ? Icons.sensors + : Icons.call_to_action; - final actionLabel = widget.selectorType == SelectorType.trigger - ? 'Action Trigger' - : 'Reaction'; - final actionHint = widget.selectorType == SelectorType.trigger - ? 'Choose the triggering action' - : 'Choose the action to perform'; - final actionHelperText = widget.selectorType == SelectorType.trigger - ? 'Select the action that will trigger your automation' - : 'Select the action that your automation should perform'; - final actionIcon = widget.selectorType == SelectorType.trigger - ? Icons.play_circle_outline - : Icons.flash_on; + final actionLabel = widget.selectorType == SelectorType.trigger + ? 'Action Trigger' + : 'Reaction'; + final actionHint = widget.selectorType == SelectorType.trigger + ? 'Choose the triggering action' + : 'Choose the action to perform'; + final actionHelperText = widget.selectorType == SelectorType.trigger + ? 'Select the action that will trigger your automation' + : 'Select the action that your automation should perform'; + final actionIcon = widget.selectorType == SelectorType.trigger + ? Icons.play_circle_outline + : Icons.flash_on; - final serviceValidatorMessage = - widget.selectorType == SelectorType.trigger - ? 'Please select a trigger service' - : 'Please select an action service'; - final actionValidatorMessage = - widget.selectorType == SelectorType.trigger - ? 'Please select a trigger action' - : 'Please select a reaction'; + final serviceValidatorMessage = + widget.selectorType == SelectorType.trigger + ? 'Please select a trigger service' + : 'Please select an action service'; + final actionValidatorMessage = + widget.selectorType == SelectorType.trigger + ? 'Please select a trigger action' + : 'Please select a reaction'; - return Card( - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - widget.titleIcon, - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - widget.title, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.w600), - ), - ), - // Toggle for filtering by connected services - if (widget.filterByConnectedServices) - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Connected only', - style: Theme.of(context).textTheme.bodySmall, - ), - Switch( - value: _showOnlyConnected, - onChanged: (value) { - setState(() { - _showOnlyConnected = value; - }); - }, - activeThumbColor: Theme.of( - context, - ).colorScheme.primary, - ), - ], - ), - ], - ), - - // Info banner when filter is on but no services match - if (widget.filterByConnectedServices && - _showOnlyConnected && - filteredServices.isEmpty) - Container( - margin: const EdgeInsets.only(top: 12, bottom: 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.errorContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Row( + Row( children: [ Icon( - Icons.warning_amber_rounded, - color: Theme.of(context).colorScheme.error, - size: 20, + widget.titleIcon, + color: Theme.of(context).colorScheme.primary, ), const SizedBox(width: 8), Expanded( child: Text( - 'No connected services available. Please connect a service or turn off the filter.', - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: Theme.of( - context, - ).colorScheme.onErrorContainer, - ), + widget.title, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w600), ), ), + // Toggle for filtering by connected services + if (widget.filterByConnectedServices) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Connected only', + style: Theme.of(context).textTheme.bodySmall, + ), + Switch( + value: _showOnlyConnected, + onChanged: (value) { + setState(() { + _showOnlyConnected = value; + }); + }, + activeThumbColor: Theme.of( + context, + ).colorScheme.primary, + ), + ], + ), ], ), - ), - const SizedBox(height: 16), - - // Service Selection - Semantics( - label: '${widget.title} service selection', - hint: serviceHelperText, - child: DropdownButtonFormField( - isExpanded: true, - initialValue: widget.selectedService, - decoration: InputDecoration( - labelText: serviceLabel, - hintText: serviceHint, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - prefixIcon: Icon(serviceIcon), - helperText: serviceHelperText, - ), - items: filteredServices.map((service) { - final isConnected = connectedProvider.isServiceConnected( - service.name, - ); - final requiresOAuth = ServiceProviderConfig.requiresOAuth( - service.name, - ); - - return DropdownMenuItem( - value: service.name, + // Info banner when filter is on but no services match + if (widget.filterByConnectedServices && + _showOnlyConnected && + filteredServices.isEmpty) + Container( + margin: const EdgeInsets.only(top: 12, bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), child: Row( children: [ Icon( - ServiceIcons.getServiceIcon(service.name), - size: 18, - color: Theme.of(context).colorScheme.primary, + Icons.warning_amber_rounded, + color: Theme.of(context).colorScheme.error, + size: 20, ), const SizedBox(width: 8), - Expanded(child: Text(service.displayName)), - if (requiresOAuth && isConnected) - Icon( - Icons.check_circle, - size: 16, - color: Colors.green, + Expanded( + child: Text( + 'No connected services available. Please connect a service or turn off the filter.', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onErrorContainer, + ), ), + ), ], ), - ); - }).toList(), - onChanged: widget.onServiceChanged, - validator: (value) { - if (value == null || value.isEmpty) { - return serviceValidatorMessage; - } - return null; - }, - ), - ), + ), - const SizedBox(height: 16), + const SizedBox(height: 16), - // Action/Reaction Selection - Only show when service is selected - if (widget.selectedService != null) - Semantics( - label: '${widget.title} action selection', - hint: actionHelperText, - child: DropdownButtonFormField( - isExpanded: true, - initialValue: widget.selectedAction, - decoration: InputDecoration( - labelText: actionLabel, - hintText: actionHint, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), + // Service Selection + Semantics( + label: '${widget.title} service selection', + hint: serviceHelperText, + child: DropdownButtonFormField( + isExpanded: true, + initialValue: widget.selectedService, + decoration: InputDecoration( + labelText: serviceLabel, + hintText: serviceHint, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + prefixIcon: Icon(serviceIcon), + helperText: serviceHelperText, ), - prefixIcon: Icon(actionIcon), - helperText: actionHelperText, + items: filteredServices.map((service) { + final isConnected = connectedProvider + .isServiceConnected(service.name); + final requiresOAuth = service.requiresOAuth; + + return DropdownMenuItem( + value: service.name, + child: Row( + children: [ + Icon( + ServiceIcons.getServiceIcon(service.name), + size: 18, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded(child: Text(service.displayName)), + if (requiresOAuth && isConnected) + Icon( + Icons.check_circle, + size: 16, + color: Colors.green, + ), + ], + ), + ); + }).toList(), + onChanged: widget.onServiceChanged, + validator: (value) { + if (value == null || value.isEmpty) { + return serviceValidatorMessage; + } + return null; + }, ), - items: - (widget.selectorType == SelectorType.trigger - ? serviceProvider - .getActionsForService( - widget.selectedService!, - ) - ?.map((action) { - return DropdownMenuItem( - value: action.name, - child: Text(action.displayName), - ); - }) - .toList() - : serviceProvider - .getReactionsForService( - widget.selectedService!, - ) - ?.map((reaction) { - return DropdownMenuItem( - value: reaction.name, - child: Text(reaction.displayName), - ); - }) - .toList()) ?? - [], - onChanged: widget.onActionChanged, - validator: (value) { - if (value == null || value.isEmpty) { - return actionValidatorMessage; - } - return null; - }, ), - ), - ], - ), - ), - ); - }, + + const SizedBox(height: 16), + + // Action/Reaction Selection - Only show when service is selected + if (widget.selectedService != null) + Semantics( + label: '${widget.title} action selection', + hint: actionHelperText, + child: DropdownButtonFormField( + isExpanded: true, + initialValue: widget.selectedAction, + decoration: InputDecoration( + labelText: actionLabel, + hintText: actionHint, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + prefixIcon: Icon(actionIcon), + helperText: actionHelperText, + ), + items: + (widget.selectorType == SelectorType.trigger + ? serviceProvider + .getActionsForService( + widget.selectedService!, + ) + ?.map((action) { + return DropdownMenuItem( + value: action.name, + child: Text(action.displayName), + ); + }) + .toList() + : _getFilteredReactions( + serviceProvider, + compatibilityProvider, + )) ?? + [], + onChanged: widget.onActionChanged, + validator: (value) { + if (value == null || value.isEmpty) { + return actionValidatorMessage; + } + return null; + }, + ), + ), + ], + ), + ), + ); + }, ); } } diff --git a/mobile/lib/widgets/google_sign_in_button.dart b/mobile/lib/widgets/google_sign_in_button.dart index a31f3614..9970e0ac 100644 --- a/mobile/lib/widgets/google_sign_in_button.dart +++ b/mobile/lib/widgets/google_sign_in_button.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import '../config/service_provider_config.dart'; class GoogleSignInButton extends StatelessWidget { @@ -15,10 +16,12 @@ class GoogleSignInButton extends StatelessWidget { return SizedBox( height: 50, child: OutlinedButton.icon( - icon: Image.network( - ServiceProviderConfig.getIconUrl('google'), + icon: SvgPicture.network( + 'https://www.gstatic.com/images/branding/product/1x/goog_onboarding_logo_2x_light.svg', height: 24, width: 24, + placeholderBuilder: (context) => + const SizedBox(height: 24, width: 24), ), label: isLoading ? const SizedBox( diff --git a/mobile/lib/widgets/service_card.dart b/mobile/lib/widgets/service_card.dart index cb09157b..c026bc1f 100644 --- a/mobile/lib/widgets/service_card.dart +++ b/mobile/lib/widgets/service_card.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import '../models/service.dart'; import '../utils/service_icons.dart'; @@ -20,7 +21,7 @@ class ServiceCard extends StatelessWidget { padding: const EdgeInsets.all(16.0), child: Row( children: [ - // Service Icon + // Service Icon/Logo Container( width: 48, height: 48, @@ -28,11 +29,27 @@ class ServiceCard extends StatelessWidget { color: Theme.of(context).primaryColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), - child: Icon( - ServiceIcons.getServiceIcon(service.name), - color: Theme.of(context).primaryColor, - size: 24, - ), + child: service.logo != null + ? SvgPicture.network( + service.logo!, + width: 24, + height: 24, + placeholderBuilder: (context) => Icon( + ServiceIcons.getServiceIcon(service.name), + color: Theme.of(context).primaryColor, + size: 24, + ), + errorBuilder: (context, error, stackTrace) => Icon( + ServiceIcons.getServiceIcon(service.name), + color: Theme.of(context).primaryColor, + size: 24, + ), + ) + : Icon( + ServiceIcons.getServiceIcon(service.name), + color: Theme.of(context).primaryColor, + size: 24, + ), ), const SizedBox(width: 16), diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 67e7db76..89fc33a0 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -286,6 +286,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: b9c2ad5872518a27507ab432d1fb97e8813b05f0fc693f9d40fad06d073e0678 + url: "https://pub.dev" + source: hosted + version: "2.2.1" flutter_test: dependency: "direct dev" description: flutter @@ -528,6 +536,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: transitive description: @@ -576,6 +592,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + url: "https://pub.dev" + source: hosted + version: "7.0.1" platform: dependency: transitive description: @@ -837,6 +861,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.4" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + url: "https://pub.dev" + source: hosted + version: "1.1.19" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc + url: "https://pub.dev" + source: hosted + version: "1.1.19" vector_math: dependency: transitive description: @@ -901,6 +949,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" yaml: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 1b2dcd8e..abb7c94b 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -44,6 +44,7 @@ dependencies: url_launcher: ^6.3.1 intl: ^0.19.0 collection: ^1.18.0 + flutter_svg: ^2.0.0 dev_dependencies: flutter_test: diff --git a/mobile/test/service_card_test.dart b/mobile/test/service_card_test.dart index bbe9ed7c..a052cefe 100644 --- a/mobile/test/service_card_test.dart +++ b/mobile/test/service_card_test.dart @@ -8,7 +8,13 @@ void main() { late Service testService; setUp(() { - testService = Service(id: 1, name: 'github', actions: [], reactions: []); + testService = Service( + id: 1, + name: 'github', + requiresOAuth: true, + actions: [], + reactions: [], + ); }); testWidgets('should display service name correctly', ( @@ -29,6 +35,7 @@ void main() { final serviceWithData = Service( id: 2, name: 'discord', + requiresOAuth: false, actions: [ ServiceAction( id: 1, @@ -59,9 +66,27 @@ void main() { WidgetTester tester, ) async { final services = [ - Service(id: 1, name: 'github', actions: [], reactions: []), - Service(id: 2, name: 'discord', actions: [], reactions: []), - Service(id: 3, name: 'unknown', actions: [], reactions: []), + Service( + id: 1, + name: 'github', + requiresOAuth: true, + actions: [], + reactions: [], + ), + Service( + id: 2, + name: 'discord', + requiresOAuth: false, + actions: [], + reactions: [], + ), + Service( + id: 3, + name: 'unknown', + requiresOAuth: false, + actions: [], + reactions: [], + ), ]; for (final service in services) { diff --git a/mobile/test/service_catalog_provider_test.dart b/mobile/test/service_catalog_provider_test.dart index f45d0879..5ecb6716 100644 --- a/mobile/test/service_catalog_provider_test.dart +++ b/mobile/test/service_catalog_provider_test.dart @@ -51,6 +51,7 @@ void main() { final service = Service( id: 1, name: 'test', + requiresOAuth: false, actions: [ ServiceAction(id: 1, name: 'action1', description: 'Test action'), ], diff --git a/mobile/test/service_models_test.dart b/mobile/test/service_models_test.dart index 65fe7084..4337c988 100644 --- a/mobile/test/service_models_test.dart +++ b/mobile/test/service_models_test.dart @@ -59,6 +59,7 @@ void main() { final service = Service( id: 2, name: 'discord', + requiresOAuth: true, actions: [ ServiceAction( id: 3, @@ -87,26 +88,40 @@ void main() { test('Service.displayName should format names correctly', () { expect( - Service(id: 1, name: 'github', actions: [], reactions: []).displayName, + Service( + id: 1, + name: 'github', + requiresOAuth: true, + actions: [], + reactions: [], + ).displayName, 'Github', ); expect( Service( id: 2, name: 'discord_bot', + requiresOAuth: false, actions: [], reactions: [], ).displayName, 'Discord Bot', ); expect( - Service(id: 3, name: '', actions: [], reactions: []).displayName, + Service( + id: 3, + name: '', + requiresOAuth: false, + actions: [], + reactions: [], + ).displayName, 'Unknown Service', ); expect( Service( id: 4, name: 'test_service', + requiresOAuth: false, actions: [], reactions: [], ).displayName, @@ -118,6 +133,7 @@ void main() { final service = Service( id: 1, name: 'github', + requiresOAuth: true, actions: [], reactions: [], );