Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
136 changes: 96 additions & 40 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 All @@ -156,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();
}
Expand Down Expand Up @@ -210,11 +220,17 @@ 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;
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 +263,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 +331,7 @@ class AddAppProvider extends ChangeNotifier {
return;
}
selectePaymentPlan = plan;
checkValidity();
notifyListeners();
}

Expand Down Expand Up @@ -338,49 +379,62 @@ 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;
}
}
if (chatPromptController.text != app.chatPrompt) {
return true;
}
if (conversationPromptController.text != app.conversationPrompt) {
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;
}

// 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. 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 (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 <String>[]).toSet();
if (!setEquals(selectedScopes.map((s) => s.id).toSet(), originalScopes)) 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 @@ -805,6 +859,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);
Expand Down Expand Up @@ -964,6 +1019,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
Loading
Loading