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