Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 86 additions & 35 deletions app/lib/pages/apps/providers/add_app_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -141,6 +146,7 @@ class AddAppProvider extends ChangeNotifier {
selectePaymentPlan = null;
}
isPaid = paid;
checkValidity();
notifyListeners();
}

Expand Down Expand Up @@ -214,7 +220,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();
}
Expand Down Expand Up @@ -247,6 +257,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() {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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) {
Expand Down Expand Up @@ -291,6 +325,7 @@ class AddAppProvider extends ChangeNotifier {
return;
}
selectePaymentPlan = plan;
checkValidity();
notifyListeners();
}

Expand Down Expand Up @@ -338,49 +373,64 @@ 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;
// 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<String> 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 (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;
}
}
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;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

// 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;
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;
}
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
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;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

return false;
}

void checkValidity() {
isValid = isFormValid();
hasChanges = _originalApp != null && hasDataChanged(_originalApp!, _originalApp!.category);
notifyListeners();
}

Expand Down Expand Up @@ -964,6 +1014,7 @@ class AddAppProvider extends ChangeNotifier {
return;
}
makeAppPublic = value;
checkValidity();
notifyListeners();
}

Expand Down
4 changes: 2 additions & 2 deletions app/lib/pages/apps/update_app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,7 @@ class _UpdateAppPageState extends State<UpdateAppPage> {
),
),
child: GestureDetector(
onTap: !provider.isValid
onTap: (!provider.isValid || !provider.hasChanges)
? null
: () {
var isValid = provider.validateForm();
Expand Down Expand Up @@ -492,7 +492,7 @@ class _UpdateAppPageState extends State<UpdateAppPage> {
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,
Expand Down
162 changes: 162 additions & 0 deletions app/test/providers/add_app_provider_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
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': <String>[],
});

// 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);
});
});

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': <String>[],
'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);
});
});
}
Loading