From 38d0559a87c7be98de5702bbd2aef647bdcae82d Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Fri, 19 Jun 2026 15:10:09 +0000 Subject: [PATCH 01/11] feat(apps): track form dirty state for Update App button Add hasChanges + _originalApp to AddAppProvider. prepareUpdate snapshots the app and attaches change listeners to all text controllers; checkValidity recomputes hasChanges via a corrected hasDataChanged that compares all editable fields (metadata, capabilities by id, pricing only when paid, prompts, source url, external integration, proactive scopes, thumbnails) order-insensitively. --- .../apps/providers/add_app_provider.dart | 114 ++++++++++++------ 1 file changed, 79 insertions(+), 35 deletions(-) diff --git a/app/lib/pages/apps/providers/add_app_provider.dart b/app/lib/pages/apps/providers/add_app_provider.dart index 91f9172f1b7..8b6dbaeb56a 100644 --- a/app/lib/pages/apps/providers/add_app_provider.dart +++ b/app/lib/pages/apps/providers/add_app_provider.dart @@ -76,8 +76,13 @@ class AddAppProvider extends ChangeNotifier { bool isUpdating = false; bool isSubmitting = false; bool isValid = false; + bool hasChanges = false; bool isGenratingDescription = false; + // Snapshot of the app being edited, used to detect whether the update form is dirty. + App? _originalApp; + bool _changeListenersAttached = false; + bool allowPaidApps = false; // API Keys @@ -214,7 +219,11 @@ class AddAppProvider extends ChangeNotifier { thumbnailUrls = app.thumbnailUrls; thumbnailIds = app.thumbnailIds; + _originalApp = app; isValid = false; + hasChanges = false; + // Attach after initial values are set so seeding the form doesn't mark it dirty. + _ensureChangeListeners(); setIsLoading(false); notifyListeners(); } @@ -247,6 +256,30 @@ class AddAppProvider extends ChangeNotifier { thumbnailUrls = []; thumbnailIds = []; actions.clear(); + _originalApp = null; + hasChanges = false; + } + + // Re-run validity/dirty checks as the user types in any text field. Idempotent. + void _ensureChangeListeners() { + if (_changeListenersAttached) return; + _changeListenersAttached = true; + for (final controller in [ + appNameController, + appDescriptionController, + chatPromptController, + conversationPromptController, + webhookUrlController, + setupCompletedController, + instructionsController, + authUrlController, + appHomeUrlController, + chatToolsManifestUrlController, + sourceCodeUrlController, + priceController, + ]) { + controller.addListener(checkValidity); + } } void addSpecificAction(String actionTypeId) { @@ -338,49 +371,60 @@ class AddAppProvider extends ChangeNotifier { } bool hasDataChanged(App app, String category) { - if (imageFile != null) { - return true; - } - if (appNameController.text != app.name) { - return true; - } - if (appDescriptionController.text != app.description) { - return true; - } - if (makeAppPublic != !app.private) { - return true; - } - if (appCategory != category) { - return true; - } - if (selectedCapabilities.length != app.capabilities.length) { - return true; - } - if (app.externalIntegration != null) { - if (triggerEvent != app.externalIntegration!.triggersOn) { - return true; - } - if (webhookUrlController.text != app.externalIntegration!.webhookUrl) { - return true; - } - if (setupCompletedController.text != app.externalIntegration!.setupCompletedUrl) { - return true; - } - if (instructionsController.text != app.externalIntegration!.setupInstructionsFilePath) { - return true; - } + // A newly picked logo always counts as a change. + if (imageFile != null) return true; + + // Metadata. app.* strings are stored encoded; compare against the decoded form + // since that's what the controllers hold. + if (appNameController.text != app.name.decodeString) return true; + if (appDescriptionController.text != app.description.decodeString) return true; + if (makeAppPublic != !app.private) return true; + if (appCategory != category) return true; + + // Capabilities — app.capabilities is a Set of ids; selectedCapabilities + // are AppCapability objects. Compare by id, order-insensitive. + if (!setEquals(selectedCapabilities.map((c) => c.id).toSet(), app.capabilities.toSet())) return true; + + // Pricing — only compare price/plan when the app is (still) paid, otherwise an + // empty price field on a free app would read as a false change. + if (isPaid != app.isPaid) return true; + if (isPaid) { + if (selectePaymentPlan != app.paymentPlan) return true; + if ((double.tryParse(priceController.text) ?? 0.0) != (app.price ?? 0.0)) return true; } - if (chatPromptController.text != app.chatPrompt) { - return true; + + // Prompts. + if (conversationPromptController.text != (app.conversationPrompt ?? '').decodeString) return true; + if (chatPromptController.text != (app.chatPrompt ?? '').decodeString) return true; + + // Source code URL. + if (sourceCodeUrlController.text != (app.sourceCodeUrl ?? '')) return true; + + // External integration. + final ext = app.externalIntegration; + if (ext != null) { + if (triggerEvent != ext.triggersOn) return true; + if (webhookUrlController.text != (ext.webhookUrl ?? '')) return true; + if (setupCompletedController.text != (ext.setupCompletedUrl ?? '')) return true; + if (instructionsController.text != (ext.setupInstructionsFilePath ?? '')) return true; + if (appHomeUrlController.text != (ext.appHomeUrl ?? '')) return true; + if (chatToolsManifestUrlController.text != (ext.chatToolsManifestUrl ?? '')) return true; } - if (conversationPromptController.text != app.conversationPrompt) { - return true; + + // Proactive notification scopes. + if (app.proactiveNotification != null) { + if (!setEquals(selectedScopes.map((s) => s.id).toSet(), app.proactiveNotification!.scopes.toSet())) return true; } + + // Thumbnails (order-insensitive). + if (!setEquals(thumbnailIds.toSet(), app.thumbnailIds.toSet())) return true; + return false; } void checkValidity() { isValid = isFormValid(); + hasChanges = _originalApp != null && hasDataChanged(_originalApp!, _originalApp!.category); notifyListeners(); } From 1f949e77ab239a751519d87bed0e93300c214536 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Fri, 19 Jun 2026 15:10:10 +0000 Subject: [PATCH 02/11] feat(apps): disable Update App button until there are unsaved changes Gate onTap and the enabled color on isValid && hasChanges so a no-op state is non-clickable and visibly greyed; reverting all edits disables it again. --- app/lib/pages/apps/update_app.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/lib/pages/apps/update_app.dart b/app/lib/pages/apps/update_app.dart index abdd6fcd6fa..6dd84c8cab2 100644 --- a/app/lib/pages/apps/update_app.dart +++ b/app/lib/pages/apps/update_app.dart @@ -464,7 +464,7 @@ class _UpdateAppPageState extends State { ), ), child: GestureDetector( - onTap: !provider.isValid + onTap: (!provider.isValid || !provider.hasChanges) ? null : () { var isValid = provider.validateForm(); @@ -492,7 +492,7 @@ class _UpdateAppPageState extends State { padding: const EdgeInsets.all(12.0), decoration: BoxDecoration( borderRadius: BorderRadius.circular(12.0), - color: provider.isValid ? Colors.white : Colors.grey.shade700, + color: (provider.isValid && provider.hasChanges) ? Colors.white : Colors.grey.shade700, ), child: Text( context.l10n.updateApp, From 61999064feb17f7a3d930939ab91d9ae4886829a Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Fri, 19 Jun 2026 15:10:10 +0000 Subject: [PATCH 03/11] test(apps): cover AddAppProvider dirty-state detection --- app/test/providers/add_app_provider_test.dart | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 app/test/providers/add_app_provider_test.dart diff --git a/app/test/providers/add_app_provider_test.dart b/app/test/providers/add_app_provider_test.dart new file mode 100644 index 00000000000..444f0684de4 --- /dev/null +++ b/app/test/providers/add_app_provider_test.dart @@ -0,0 +1,102 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:omi/backend/schema/app.dart'; +import 'package:omi/pages/apps/providers/add_app_provider.dart'; + +// Builds a free, private "conversation prompt template" style app. +App _buildApp() => App.fromJson({ + 'id': 'app_123', + 'name': 'My Template', + 'author': 'Jane', + 'description': 'A test template', + 'image': 'https://img/logo.png', + 'category': 'productivity-and-organization', + 'capabilities': ['memories'], + 'memory_prompt': 'Summarize the conversation', + 'private': true, + 'is_paid': false, + 'price': 0.0, + 'thumbnails': [], + }); + +// Seeds the provider so its form state matches [app] exactly (mirrors prepareUpdate +// without the network calls), so hasDataChanged should report no changes. +void _seedMatching(AddAppProvider p, App app) { + p.appNameController.text = app.name; + p.appDescriptionController.text = app.description; + p.conversationPromptController.text = app.conversationPrompt ?? ''; + p.chatPromptController.text = app.chatPrompt ?? ''; + p.sourceCodeUrlController.text = app.sourceCodeUrl ?? ''; + p.makeAppPublic = !app.private; + p.appCategory = app.category; + p.isPaid = app.isPaid; + p.selectePaymentPlan = app.paymentPlan; + p.selectedCapabilities = app.capabilities.map((id) => AppCapability(title: id, id: id)).toList(); + p.thumbnailIds = List.of(app.thumbnailIds); +} + +void main() { + group('AddAppProvider.hasDataChanged', () { + late App app; + late AddAppProvider provider; + + setUp(() { + app = _buildApp(); + provider = AddAppProvider(); + _seedMatching(provider, app); + }); + + test('no changes on initial load', () { + expect(provider.hasDataChanged(app, app.category), isFalse); + }); + + test('free app with an empty price field is not dirty', () { + // Regression: empty price field on a free app must not read as a change. + provider.priceController.text = ''; + expect(provider.hasDataChanged(app, app.category), isFalse); + }); + + test('editing the name marks dirty, reverting clears it', () { + provider.appNameController.text = 'My Template v2'; + expect(provider.hasDataChanged(app, app.category), isTrue); + provider.appNameController.text = app.name; + expect(provider.hasDataChanged(app, app.category), isFalse); + }); + + test('editing the description marks dirty', () { + provider.appDescriptionController.text = 'changed'; + expect(provider.hasDataChanged(app, app.category), isTrue); + }); + + test('toggling visibility marks dirty', () { + provider.makeAppPublic = !provider.makeAppPublic; + expect(provider.hasDataChanged(app, app.category), isTrue); + }); + + test('changing the category marks dirty', () { + provider.appCategory = 'a-different-category'; + expect(provider.hasDataChanged(app, app.category), isTrue); + }); + + test('adding a capability marks dirty', () { + provider.selectedCapabilities = [...provider.selectedCapabilities, AppCapability(title: 'chat', id: 'chat')]; + expect(provider.hasDataChanged(app, app.category), isTrue); + }); + + test('editing the conversation prompt marks dirty', () { + provider.conversationPromptController.text = 'a new prompt'; + expect(provider.hasDataChanged(app, app.category), isTrue); + }); + + test('switching to paid marks dirty', () { + provider.isPaid = true; + expect(provider.hasDataChanged(app, app.category), isTrue); + }); + + test('picking a new logo marks dirty', () { + provider.imageFile = File('new_logo.png'); + expect(provider.hasDataChanged(app, app.category), isTrue); + }); + }); +} From dc8378d623d89690710c006ca4a502a73a29e74c Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Fri, 19 Jun 2026 16:03:39 +0000 Subject: [PATCH 04/11] fix(apps): recompute dirty state on visibility/paid/plan + cover auth url & actions Per cubic review: - setIsPaid / setPaymentPlan / setIsPrivate only called notifyListeners, so toggling visibility/paid/plan left hasChanges stale and the button disabled. Add checkValidity() to each. - hasDataChanged omitted the external-integration auth URL and actions, so editing only those kept hasChanges false. Compare both (actions order-insensitive). --- app/lib/pages/apps/providers/add_app_provider.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/lib/pages/apps/providers/add_app_provider.dart b/app/lib/pages/apps/providers/add_app_provider.dart index 8b6dbaeb56a..3ae2fbd5282 100644 --- a/app/lib/pages/apps/providers/add_app_provider.dart +++ b/app/lib/pages/apps/providers/add_app_provider.dart @@ -146,6 +146,7 @@ class AddAppProvider extends ChangeNotifier { selectePaymentPlan = null; } isPaid = paid; + checkValidity(); notifyListeners(); } @@ -324,6 +325,7 @@ class AddAppProvider extends ChangeNotifier { return; } selectePaymentPlan = plan; + checkValidity(); notifyListeners(); } @@ -409,6 +411,10 @@ class AddAppProvider extends ChangeNotifier { if (instructionsController.text != (ext.setupInstructionsFilePath ?? '')) return true; if (appHomeUrlController.text != (ext.appHomeUrl ?? '')) return true; if (chatToolsManifestUrlController.text != (ext.chatToolsManifestUrl ?? '')) return true; + final originalAuthUrl = ext.authSteps.isNotEmpty ? ext.authSteps.first.url : ''; + if (authUrlController.text != originalAuthUrl) return true; + final originalActions = (ext.actions ?? []).map((a) => a.action).toSet(); + if (!setEquals(actions.map((a) => a['action'] as String).toSet(), originalActions)) return true; } // Proactive notification scopes. @@ -1008,6 +1014,7 @@ class AddAppProvider extends ChangeNotifier { return; } makeAppPublic = value; + checkValidity(); notifyListeners(); } From 126cbcf9474e325754028e56c958cac63452b65b Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Fri, 19 Jun 2026 16:03:39 +0000 Subject: [PATCH 05/11] test(apps): cover external-integration auth url & actions dirty checks --- app/test/providers/add_app_provider_test.dart | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/app/test/providers/add_app_provider_test.dart b/app/test/providers/add_app_provider_test.dart index 444f0684de4..49b75d73a0c 100644 --- a/app/test/providers/add_app_provider_test.dart +++ b/app/test/providers/add_app_provider_test.dart @@ -99,4 +99,64 @@ void main() { expect(provider.hasDataChanged(app, app.category), isTrue); }); }); + + group('AddAppProvider.hasDataChanged — external integration', () { + late App app; + late AddAppProvider provider; + + App buildExtApp() => App.fromJson({ + 'id': 'ext_app', + 'name': 'Hooky', + 'author': 'Jane', + 'description': 'integration app', + 'image': 'https://img/logo.png', + 'category': 'productivity-and-organization', + 'capabilities': ['external_integration'], + 'private': true, + 'is_paid': false, + 'price': 0.0, + 'thumbnails': [], + 'external_integration': { + 'triggers_on': 'memory_creation', + 'webhook_url': 'https://hook', + 'auth_steps': [ + {'url': 'https://auth', 'name': 'Setup'}, + ], + 'actions': [ + {'action': 'create_conversation'}, + ], + }, + }); + + setUp(() { + app = buildExtApp(); + provider = AddAppProvider(); + _seedMatching(provider, app); + final ext = app.externalIntegration!; + provider.triggerEvent = ext.triggersOn; + provider.webhookUrlController.text = ext.webhookUrl ?? ''; + provider.setupCompletedController.text = ext.setupCompletedUrl ?? ''; + provider.instructionsController.text = ext.setupInstructionsFilePath ?? ''; + provider.appHomeUrlController.text = ext.appHomeUrl ?? ''; + provider.chatToolsManifestUrlController.text = ext.chatToolsManifestUrl ?? ''; + provider.authUrlController.text = ext.authSteps.isNotEmpty ? ext.authSteps.first.url : ''; + provider.actions = (ext.actions ?? []).map((a) => {'action': a.action}).toList(); + }); + + test('no changes on initial load', () { + expect(provider.hasDataChanged(app, app.category), isFalse); + }); + + test('editing the auth URL marks dirty', () { + provider.authUrlController.text = 'https://auth/changed'; + expect(provider.hasDataChanged(app, app.category), isTrue); + }); + + test('changing actions marks dirty', () { + provider.actions = [ + {'action': 'read_conversations'}, + ]; + expect(provider.hasDataChanged(app, app.category), isTrue); + }); + }); } From 3d6408c91c2784195110739110256f4eb454ed05 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Fri, 19 Jun 2026 18:04:07 +0000 Subject: [PATCH 06/11] fix(apps): copy thumbnail lists so dirty check detects thumbnail edits Per cubic review: prepareUpdate assigned app's thumbnail lists by reference, so in-place add/remove also mutated the _originalApp snapshot and setEquals always matched -> Update stayed disabled for thumbnail-only changes. Copy the lists, and call checkValidity() after a thumbnail upload (removeThumbnail already did). --- app/lib/pages/apps/providers/add_app_provider.dart | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/lib/pages/apps/providers/add_app_provider.dart b/app/lib/pages/apps/providers/add_app_provider.dart index 3ae2fbd5282..fe1aceb7a50 100644 --- a/app/lib/pages/apps/providers/add_app_provider.dart +++ b/app/lib/pages/apps/providers/add_app_provider.dart @@ -216,9 +216,11 @@ class AddAppProvider extends ChangeNotifier { ); } - // Set existing thumbnails - thumbnailUrls = app.thumbnailUrls; - thumbnailIds = app.thumbnailIds; + // Set existing thumbnails. Copy the lists — assigning app's lists directly would + // alias them, so in-place add/remove would also mutate the _originalApp snapshot + // and the dirty check would never detect thumbnail changes. + thumbnailUrls = List.of(app.thumbnailUrls); + thumbnailIds = List.of(app.thumbnailIds); _originalApp = app; isValid = false; @@ -855,6 +857,7 @@ class AddAppProvider extends ChangeNotifier { if (result.isNotEmpty) { thumbnailUrls.add(result['thumbnail_url']!); thumbnailIds.add(result['thumbnail_id']!); + checkValidity(); Logger.debug('🖼️ Thumbnail uploaded successfully'); } setIsUploadingThumbnail(false); From d172636208028a809beb59559d800b38c4dde747 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Fri, 19 Jun 2026 18:04:07 +0000 Subject: [PATCH 07/11] test(apps): cover thumbnail dirty-state detection --- app/test/providers/add_app_provider_test.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/test/providers/add_app_provider_test.dart b/app/test/providers/add_app_provider_test.dart index 49b75d73a0c..7578cdb460a 100644 --- a/app/test/providers/add_app_provider_test.dart +++ b/app/test/providers/add_app_provider_test.dart @@ -98,6 +98,11 @@ void main() { provider.imageFile = File('new_logo.png'); expect(provider.hasDataChanged(app, app.category), isTrue); }); + + test('adding a thumbnail marks dirty (lists must not be aliased to the snapshot)', () { + provider.thumbnailIds = [...provider.thumbnailIds, 'thumb_1']; + expect(provider.hasDataChanged(app, app.category), isTrue); + }); }); group('AddAppProvider.hasDataChanged — external integration', () { From ed07baeb096c4fe87f65d8dcd8be5b3ff830f1c9 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Fri, 19 Jun 2026 19:09:26 +0000 Subject: [PATCH 08/11] fix(apps): null-safe external-integration + scopes dirty checks Per cubic: the external-integration comparison was gated on ext != null, so adding those fields to an app that had no integration wasn't detected. Compare null-safe instead. Applied the same null-safe treatment to proactive-notification scopes to avoid the identical gap. --- .../apps/providers/add_app_provider.dart | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/app/lib/pages/apps/providers/add_app_provider.dart b/app/lib/pages/apps/providers/add_app_provider.dart index fe1aceb7a50..1c95020e7be 100644 --- a/app/lib/pages/apps/providers/add_app_provider.dart +++ b/app/lib/pages/apps/providers/add_app_provider.dart @@ -404,25 +404,23 @@ class AddAppProvider extends ChangeNotifier { // Source code URL. if (sourceCodeUrlController.text != (app.sourceCodeUrl ?? '')) return true; - // External integration. + // External integration. Compare null-safe (not gated on ext != null) so adding + // these fields to an app that previously had no integration is also detected. final ext = app.externalIntegration; - if (ext != null) { - if (triggerEvent != ext.triggersOn) return true; - if (webhookUrlController.text != (ext.webhookUrl ?? '')) return true; - if (setupCompletedController.text != (ext.setupCompletedUrl ?? '')) return true; - if (instructionsController.text != (ext.setupInstructionsFilePath ?? '')) return true; - if (appHomeUrlController.text != (ext.appHomeUrl ?? '')) return true; - if (chatToolsManifestUrlController.text != (ext.chatToolsManifestUrl ?? '')) return true; - final originalAuthUrl = ext.authSteps.isNotEmpty ? ext.authSteps.first.url : ''; - if (authUrlController.text != originalAuthUrl) return true; - final originalActions = (ext.actions ?? []).map((a) => a.action).toSet(); - if (!setEquals(actions.map((a) => a['action'] as String).toSet(), originalActions)) return true; - } - - // Proactive notification scopes. - if (app.proactiveNotification != null) { - if (!setEquals(selectedScopes.map((s) => s.id).toSet(), app.proactiveNotification!.scopes.toSet())) return true; - } + if (triggerEvent != ext?.triggersOn) return true; + if (webhookUrlController.text != (ext?.webhookUrl ?? '')) return true; + if (setupCompletedController.text != (ext?.setupCompletedUrl ?? '')) return true; + if (instructionsController.text != (ext?.setupInstructionsFilePath ?? '')) return true; + if (appHomeUrlController.text != (ext?.appHomeUrl ?? '')) return true; + if (chatToolsManifestUrlController.text != (ext?.chatToolsManifestUrl ?? '')) return true; + final originalAuthUrl = (ext?.authSteps.isNotEmpty ?? false) ? ext!.authSteps.first.url : ''; + if (authUrlController.text != originalAuthUrl) return true; + final originalActions = (ext?.actions ?? []).map((a) => a.action).toSet(); + if (!setEquals(actions.map((a) => a['action'] as String).toSet(), originalActions)) return true; + + // Proactive notification scopes (null-safe for the same reason as above). + final originalScopes = (app.proactiveNotification?.scopes ?? const []).toSet(); + if (!setEquals(selectedScopes.map((s) => s.id).toSet(), originalScopes)) return true; // Thumbnails (order-insensitive). if (!setEquals(thumbnailIds.toSet(), app.thumbnailIds.toSet())) return true; From aa11601b4ed44cea277cd0f8239d895303927d71 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Fri, 19 Jun 2026 19:09:26 +0000 Subject: [PATCH 09/11] test(apps): cover external field added to a non-integration app --- app/test/providers/add_app_provider_test.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/test/providers/add_app_provider_test.dart b/app/test/providers/add_app_provider_test.dart index 7578cdb460a..404fe0c6145 100644 --- a/app/test/providers/add_app_provider_test.dart +++ b/app/test/providers/add_app_provider_test.dart @@ -103,6 +103,12 @@ void main() { provider.thumbnailIds = [...provider.thumbnailIds, 'thumb_1']; expect(provider.hasDataChanged(app, app.category), isTrue); }); + + test('adding an external-integration field to a non-integration app marks dirty', () { + // app has no external_integration; the dirty check must still detect this. + provider.webhookUrlController.text = 'https://hook'; + expect(provider.hasDataChanged(app, app.category), isTrue); + }); }); group('AddAppProvider.hasDataChanged — external integration', () { From df83a1d3d81f5e30e8cf4ce70f9ec618e7525a97 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Fri, 19 Jun 2026 20:12:43 +0000 Subject: [PATCH 10/11] fix(apps): reset form state at start of prepareUpdate to avoid stale-dirty prepareUpdate only seeds optional fields (integration/scopes/prompts) when present on the app, and the provider is reused across add/update flows. With the dirty check now comparing those fields unconditionally, leftover values from a prior session could mark an unchanged app as dirty. Call clear() first so the baseline is always clean. --- app/lib/pages/apps/providers/add_app_provider.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/lib/pages/apps/providers/add_app_provider.dart b/app/lib/pages/apps/providers/add_app_provider.dart index 1c95020e7be..d7a0f764741 100644 --- a/app/lib/pages/apps/providers/add_app_provider.dart +++ b/app/lib/pages/apps/providers/add_app_provider.dart @@ -162,6 +162,10 @@ class AddAppProvider extends ChangeNotifier { Future prepareUpdate(App app) async { setIsLoading(true); + // Reset all form state first. The provider is reused across the add/update flows, + // and prepareUpdate only seeds optional fields (integration, scopes, prompts) when + // present on the app — without this, leftover values would read as false "changes". + clear(); if (capabilities.isEmpty) { await getAppCapabilities(); } From 51f58d330255338c87ef185f279a1972d03a752a Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Fri, 19 Jun 2026 20:12:44 +0000 Subject: [PATCH 11/11] test(apps): assert clear() resets fields read by the dirty check --- app/test/providers/add_app_provider_test.dart | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/app/test/providers/add_app_provider_test.dart b/app/test/providers/add_app_provider_test.dart index 404fe0c6145..406f6acc787 100644 --- a/app/test/providers/add_app_provider_test.dart +++ b/app/test/providers/add_app_provider_test.dart @@ -111,6 +111,36 @@ void main() { }); }); + group('AddAppProvider.clear', () { + test('resets the fields the dirty check reads (no stale state leaks in)', () { + final p = AddAppProvider(); + p.appNameController.text = 'stale'; + p.webhookUrlController.text = 'https://stale'; + p.authUrlController.text = 'https://stale-auth'; + p.sourceCodeUrlController.text = 'https://stale-src'; + p.triggerEvent = 'memory_creation'; + p.isPaid = true; + p.selectedCapabilities = [AppCapability(title: 'chat', id: 'chat')]; + p.selectedScopes = []; + p.thumbnailIds = ['t1']; + p.actions = [ + {'action': 'create_conversation'}, + ]; + + p.clear(); + + expect(p.appNameController.text, isEmpty); + expect(p.webhookUrlController.text, isEmpty); + expect(p.authUrlController.text, isEmpty); + expect(p.sourceCodeUrlController.text, isEmpty); + expect(p.triggerEvent, isNull); + expect(p.isPaid, isFalse); + expect(p.selectedCapabilities, isEmpty); + expect(p.thumbnailIds, isEmpty); + expect(p.actions, isEmpty); + }); + }); + group('AddAppProvider.hasDataChanged — external integration', () { late App app; late AddAppProvider provider;