Skip to content

feat(cookidoo): add Cookidoo recipe search integration#31

Open
using-system wants to merge 32 commits intomainfrom
feat/cookidoo-search
Open

feat(cookidoo): add Cookidoo recipe search integration#31
using-system wants to merge 32 commits intomainfrom
feat/cookidoo-search

Conversation

@using-system
Copy link
Copy Markdown
Owner

Summary

  • Add a reusable Cookidoo service layer (lib/features/cookidoo/) with domain models, HTTP client (OAuth2 auth), and repository pattern — designed for use by both the LLM chat agent and future dedicated UI screens
  • Integrate Cookidoo recipe search (no auth required) and recipe detail (auth required) via two new function calling tool handlers (search_recipes, get_recipe_detail)
  • Add a search-recipe skill that instructs the on-device LLM to automatically search Cookidoo for inspiration when the user asks for a recipe
  • Add Cookidoo credentials settings section (email + password + test connection) in the Settings page
  • Add i18n strings for Cookidoo settings in all 4 locales (en, fr, de, es)
  • Add http package dependency for REST API calls

Test plan

  • Open Settings, verify the new "Cookidoo" section appears between Recipe and AI sections
  • Tap Cookidoo tile, enter valid credentials, tap "Test" — verify success snackbar
  • Tap "Test" with invalid credentials — verify failure snackbar
  • Tap "OK" to save credentials, reopen dialog — verify credentials are persisted
  • Start a conversation, ask for a recipe (e.g. "poulet curry") — verify the agent calls search_recipes in debug logs
  • With credentials configured, verify get_recipe_detail is called for promising results
  • Without credentials, verify search still works but detail gracefully fails
  • Verify the app builds: flutter build apk --debug

🤖 Generated with Claude Code

using-system and others added 11 commits April 20, 2026 20:10
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add Cookidoo-specific localization strings for email, password, test button,
and error handling across English, French, German, and Spanish locales.
Generated l10n artifacts with flutter gen-l10n.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 20, 2026 18:59
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a Cookidoo integration layer (client + repository + models) and wires it into the app via Settings (credentials) and LLM function-calling tools/skills to enable recipe search/detail.

Changes:

  • Added Cookidoo feature module with HTTP client (OAuth2), repository, providers, and domain models.
  • Registered new LLM tools (search_recipes, get_recipe_detail) and added a new search-recipe skill asset.
  • Added Cookidoo credentials UI in Settings and localized strings across en/fr/de/es; added http dependency.

Reviewed changes

Copilot reviewed 25 out of 26 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
pubspec.yaml Adds http dependency and registers the new skill asset folder.
pubspec.lock Records http as a direct dependency.
lib/l10n/app_en.arb Adds Cookidoo Settings i18n keys + descriptions.
lib/l10n/app_fr.arb Adds French Cookidoo Settings strings.
lib/l10n/app_de.arb Adds German Cookidoo Settings strings.
lib/l10n/app_es.arb Adds Spanish Cookidoo Settings strings.
lib/features/tools/providers.dart Registers Cookidoo-backed tool handlers in the tool registry.
lib/features/tools/handlers/search_recipes_handler.dart Adds search_recipes tool definition + execution wiring.
lib/features/tools/handlers/get_recipe_detail_handler.dart Adds get_recipe_detail tool definition + execution wiring.
lib/features/skills/domain/skill_loader.dart Loads the new search-recipe skill asset.
lib/features/settings/presentation/settings_page.dart Inserts a new Cookidoo section and credentials tile in Settings.
lib/features/cookidoo/providers.dart Adds Riverpod providers for credentials storage, client, and repository.
lib/features/cookidoo/presentation/cookidoo_credentials_tile.dart Adds UI for entering/testing/saving Cookidoo credentials.
lib/features/cookidoo/domain/models/cookidoo_recipe_overview.dart Adds search result model parsing.
lib/features/cookidoo/domain/models/cookidoo_recipe_detail.dart Adds recipe detail model parsing (ingredients/steps/nutrition).
lib/features/cookidoo/domain/models/cookidoo_exceptions.dart Adds typed exceptions for auth/not-found/network errors.
lib/features/cookidoo/domain/models/cookidoo_credentials.dart Adds credentials value object.
lib/features/cookidoo/domain/models/cookidoo_auth_token.dart Adds in-memory auth token model + expiry logic.
lib/features/cookidoo/domain/cookidoo_repository.dart Defines Cookidoo repository interface.
lib/features/cookidoo/data/cookidoo_repository_impl.dart Implements repository behavior and credential gating for details.
lib/features/cookidoo/data/cookidoo_credentials_storage.dart Persists credentials in SharedPreferences.
lib/features/cookidoo/data/cookidoo_client.dart Implements Cookidoo API calls + token refresh/login.
assets/skills/search-recipe/SKILL.md Adds LLM skill instructions for Cookidoo search/detail usage.
SPEC.md Documents the new Cookidoo integration dependency.
docs/superpowers/specs/2026-04-20-cookidoo-search-design.md Adds a design spec for the Cookidoo integration.
docs/superpowers/plans/2026-04-20-cookidoo-search.md Adds an implementation plan for Cookidoo integration work.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +73 to +84
final repo = ref.read(cookidooRepositoryProvider);
final scaffoldMessenger = ScaffoldMessenger.of(context);
final success = await repo.isAuthenticated();
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
success
? l10n.settingsCookidooTestSuccess
: l10n.settingsCookidooTestFailure,
),
),
);
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "Test" action ignores the email/password currently typed in the dialog and instead calls repo.isAuthenticated() using whatever credentials are already stored in the provider. This makes “Test connection” misleading (it will fail until the user taps OK first). Consider adding a repository/client method that tests the provided credentials (or temporarily constructing a repository with the controllers’ values) and using that here.

Suggested change
final repo = ref.read(cookidooRepositoryProvider);
final scaffoldMessenger = ScaffoldMessenger.of(context);
final success = await repo.isAuthenticated();
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
success
? l10n.settingsCookidooTestSuccess
: l10n.settingsCookidooTestFailure,
),
),
);
final credentialsNotifier =
ref.read(cookidooCredentialsProvider.notifier);
final repo = ref.read(cookidooRepositoryProvider);
final scaffoldMessenger = ScaffoldMessenger.of(context);
final originalCredentials = current;
final testCredentials = CookidooCredentials(
email: emailController.text.trim(),
password: passwordController.text,
);
try {
await credentialsNotifier.setCredentials(testCredentials);
final success = await repo.isAuthenticated();
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
success
? l10n.settingsCookidooTestSuccess
: l10n.settingsCookidooTestFailure,
),
),
);
} finally {
await credentialsNotifier.setCredentials(
originalCredentials ??
const CookidooCredentials(email: '', password: ''),
);
}

Copilot uses AI. Check for mistakes.
Comment on lines +40 to +42
builder: (ctx) => AlertDialog(
title: const Text('Cookidoo'),
content: Column(
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dialog title is hard-coded (const Text('Cookidoo')) instead of using localized strings. Even if the proper noun stays the same, using i18n keys keeps the UI consistent and avoids future translation gaps.

Copilot uses AI. Check for mistakes.
Comment on lines +121 to +129
final response = await _http.get(url, headers: {
'Accept': 'application/json',
});

if (response.statusCode != 200) {
throw CookidooNetworkException(
'Search failed (${response.statusCode})',
);
}
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These HTTP calls can throw (e.g., SocketException, TLS errors) before returning a Response. Right now those exceptions will bubble out as generic errors and can break the chat stream (tool execution happens inside the response loop). Wrap requests in try/catch and convert failures/timeouts into CookidooNetworkException so callers can handle them predictably.

Copilot uses AI. Check for mistakes.
Comment on lines +49 to +53
debugPrint(
'GetRecipeDetailHandler: ${detail.title}'
'\nIngredients: ${jsonEncode(ingredients)}'
'\nSteps: ${jsonEncode(steps)}',
);
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as search_recipes: this handler only logs ingredient/step data and doesn’t provide any structured output back to the LLM, so the model can’t use the detailed recipe content. Consider returning the detail payload through the function-calling pipeline (or persisting it somewhere the prompt builder can include).

Copilot uses AI. Check for mistakes.
Comment thread assets/skills/search-recipe/SKILL.md Outdated
Comment on lines +10 to +23
When the user asks for a recipe (e.g. "make me a chicken curry", "recipe for chocolate cake"),
automatically call the `search_recipes` tool with a relevant query to find similar recipes on Cookidoo.

- query: a concise search term matching the user's request. String.
- limit: number of results, default 5. Integer.

Use the search results as **inspiration**, not as a verbatim copy.
Adapt recipes to the user's settings (portions, dietary restrictions, Thermomix version, difficulty level).

If Cookidoo credentials are configured, you can also call `get_recipe_detail` to retrieve
the full ingredients and steps of a promising recipe:

- recipe_id: the Cookidoo recipe ID from search results (e.g. "r145192"). String.

Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The skill instructs the model to “use the search results as inspiration” and optionally call get_recipe_detail based on those results, but the current tool handler implementation doesn’t surface any tool output back into the model context (it only logs). Either update the skill to match current capabilities, or implement a mechanism to feed tool outputs back to the LLM so these instructions are actionable.

Copilot uses AI. Check for mistakes.
Comment on lines +93 to +99
kcal = number;
case 'protein':
protein = number;
case 'fat':
fat = number;
case 'carbohydrates':
carbs = number;
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The last case in this switch also lacks a terminating statement. Ensure every case ends with break/return/continue so the switch compiles and doesn’t accidentally fall through via continue logic.

Suggested change
kcal = number;
case 'protein':
protein = number;
case 'fat':
fat = number;
case 'carbohydrates':
carbs = number;
kcal = number;
break;
case 'protein':
protein = number;
break;
case 'fat':
fat = number;
break;
case 'carbohydrates':
carbs = number;
break;

Copilot uses AI. Check for mistakes.
Comment on lines +19 to +23
return ListTile(
leading: const Icon(Icons.cloud_outlined),
title: const Text('Cookidoo'),
subtitle: Text(subtitle),
onTap: () => _showCredentialsDialog(context, ref, credentials),
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

title: const Text('Cookidoo') is a new user-visible string but bypasses localization. Use the existing settingsSectionCookidoo (or a dedicated title key) for consistency with the rest of Settings.

Copilot uses AI. Check for mistakes.
Comment on lines +53 to +60
final cookidooRepositoryProvider = Provider<CookidooRepository>((ref) {
final client = ref.watch(cookidooClientProvider);
final credentials = ref.watch(cookidooCredentialsProvider).valueOrNull;
final effectiveLocale = ref.watch(effectiveLocaleProvider);
final locale = effectiveLocale ??
WidgetsBinding.instance.platformDispatcher.locale;
final lang =
'${locale.languageCode}-${locale.countryCode ?? locale.languageCode.toUpperCase()}';
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the user forces a language via ForcedLocalePreference, the Locale has no countryCode (e.g. Locale('en')). This builds lang values like en-EN and a countryCode like en, which likely won’t match Cookidoo’s expected en-GB / fr-FR formats. Consider mapping language-only locales to a sensible default region (e.g. en-GB, fr-FR, de-DE, es-ES) and ensure countryCodeFromLocale stays in sync.

Suggested change
final cookidooRepositoryProvider = Provider<CookidooRepository>((ref) {
final client = ref.watch(cookidooClientProvider);
final credentials = ref.watch(cookidooCredentialsProvider).valueOrNull;
final effectiveLocale = ref.watch(effectiveLocaleProvider);
final locale = effectiveLocale ??
WidgetsBinding.instance.platformDispatcher.locale;
final lang =
'${locale.languageCode}-${locale.countryCode ?? locale.languageCode.toUpperCase()}';
String _cookidooCountryCodeFromLocale(Locale locale) {
final countryCode = locale.countryCode;
if (countryCode != null && countryCode.isNotEmpty) {
return countryCode.toUpperCase();
}
switch (locale.languageCode.toLowerCase()) {
case 'en':
return 'GB';
case 'fr':
return 'FR';
case 'de':
return 'DE';
case 'es':
return 'ES';
default:
return locale.languageCode.toUpperCase();
}
}
String _cookidooLocaleFromLocale(Locale locale) {
return '${locale.languageCode}-${_cookidooCountryCodeFromLocale(locale)}';
}
final cookidooRepositoryProvider = Provider<CookidooRepository>((ref) {
final client = ref.watch(cookidooClientProvider);
final credentials = ref.watch(cookidooCredentialsProvider).valueOrNull;
final effectiveLocale = ref.watch(effectiveLocaleProvider);
final locale = effectiveLocale ??
WidgetsBinding.instance.platformDispatcher.locale;
final lang = _cookidooLocaleFromLocale(locale);

Copilot uses AI. Check for mistakes.
Comment on lines +55 to +58
debugPrint(
'SearchRecipesHandler: ${results.length} results for "$query"'
'\n${jsonEncode(summaries)}',
);
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This tool handler only debugPrints results and doesn’t return anything to the model. Given the skill text says to use the search results as inspiration, the LLM currently can’t actually consume them. Either adjust the tool plumbing to feed tool outputs back into the chat as a message/context, or revise the tool/skill to reflect that the call is side-effect only (logging).

Copilot uses AI. Check for mistakes.
Comment on lines +2 to +22

import '../domain/models/cookidoo_credentials.dart';

class CookidooCredentialsStorage {
CookidooCredentialsStorage(this._prefs);

static const _keyEmail = 'cookidoo_email';
static const _keyPassword = 'cookidoo_password';

final SharedPreferences _prefs;

CookidooCredentials read() {
return CookidooCredentials(
email: _prefs.getString(_keyEmail) ?? '',
password: _prefs.getString(_keyPassword) ?? '',
);
}

Future<void> write(CookidooCredentials credentials) async {
await _prefs.setString(_keyEmail, credentials.email);
await _prefs.setString(_keyPassword, credentials.password);
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Credentials are persisted in plaintext via SharedPreferences. If this app may run on rooted/jailbroken devices or if credential theft is a concern, consider using platform secure storage (Keychain/Keystore via flutter_secure_storage) or at least documenting the risk and offering an opt-in/alternative.

Suggested change
import '../domain/models/cookidoo_credentials.dart';
class CookidooCredentialsStorage {
CookidooCredentialsStorage(this._prefs);
static const _keyEmail = 'cookidoo_email';
static const _keyPassword = 'cookidoo_password';
final SharedPreferences _prefs;
CookidooCredentials read() {
return CookidooCredentials(
email: _prefs.getString(_keyEmail) ?? '',
password: _prefs.getString(_keyPassword) ?? '',
);
}
Future<void> write(CookidooCredentials credentials) async {
await _prefs.setString(_keyEmail, credentials.email);
await _prefs.setString(_keyPassword, credentials.password);
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../domain/models/cookidoo_credentials.dart';
class CookidooCredentialsStorage {
CookidooCredentialsStorage(
this._prefs, {
FlutterSecureStorage? secureStorage,
}) : _secureStorage = secureStorage ?? const FlutterSecureStorage();
static const _keyEmail = 'cookidoo_email';
static const _keyPassword = 'cookidoo_password';
final SharedPreferences _prefs;
final FlutterSecureStorage _secureStorage;
Future<CookidooCredentials> read() async {
return CookidooCredentials(
email: await _secureStorage.read(key: _keyEmail) ?? '',
password: await _secureStorage.read(key: _keyPassword) ?? '',
);
}
Future<void> write(CookidooCredentials credentials) async {
await _secureStorage.write(key: _keyEmail, value: credentials.email);
await _secureStorage.write(key: _keyPassword, value: credentials.password);

Copilot uses AI. Check for mistakes.
using-system and others added 2 commits April 20, 2026 21:10
The Test button was calling repo.isAuthenticated() which used the
previously saved (empty) credentials instead of the ones typed in the
dialog fields. Now tests directly with the input field values and logs
errors for debugging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 20, 2026 19:12
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 25 out of 26 changed files in this pull request and generated 10 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +8 to +23
static const _keyEmail = 'cookidoo_email';
static const _keyPassword = 'cookidoo_password';

final SharedPreferences _prefs;

CookidooCredentials read() {
return CookidooCredentials(
email: _prefs.getString(_keyEmail) ?? '',
password: _prefs.getString(_keyPassword) ?? '',
);
}

Future<void> write(CookidooCredentials credentials) async {
await _prefs.setString(_keyEmail, credentials.email);
await _prefs.setString(_keyPassword, credentials.password);
}
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cookidoo credentials are persisted in plain text via SharedPreferences. This is not an encrypted store and can be extracted from device backups/rooted devices. If you must store a password, prefer a secure storage mechanism (Keychain/Keystore, e.g. flutter_secure_storage) or store a refresh token instead of the raw password.

Copilot uses AI. Check for mistakes.
Comment on lines +56 to +57
debugPrint('Cookidoo login: POST $url → ${response.statusCode}');
debugPrint('Cookidoo login response: ${response.body}');
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

login() logs the full response body on non-200 responses. Auth endpoints can include sensitive information in error payloads, and this will end up in device logs. Consider gating logs behind kDebugMode and redacting bodies, or logging only status code + a correlation id.

Suggested change
debugPrint('Cookidoo login: POST $url → ${response.statusCode}');
debugPrint('Cookidoo login response: ${response.body}');
if (kDebugMode) {
debugPrint('Cookidoo login: POST $url → ${response.statusCode}');
}

Copilot uses AI. Check for mistakes.
Comment on lines +55 to +60
if (response.statusCode != 200) {
debugPrint('Cookidoo login: POST $url → ${response.statusCode}');
debugPrint('Cookidoo login response: ${response.body}');
throw CookidooAuthException(
'Login failed (${response.statusCode})',
);
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On login failure (statusCode != 200) the client throws but does not clear any previously cached _token. That means the app could remain authenticated with an old token even after invalid credentials are entered/tested. Clear _token on login failure (and ideally tie cached tokens to the credential identity).

Copilot uses AI. Check for mistakes.
Comment on lines +78 to +86
final client = ref.read(cookidooClientProvider);
final scaffoldMessenger = ScaffoldMessenger.of(context);
final countryCode = CookidooClient.countryCodeFromLocale(
'${Localizations.localeOf(context).languageCode}-${Localizations.localeOf(context).countryCode ?? Localizations.localeOf(context).languageCode.toUpperCase()}',
);
debugPrint('Cookidoo test: countryCode=$countryCode, email=${testCreds.email}');
try {
await client.login(testCreds, countryCode: countryCode);
scaffoldMessenger.showSnackBar(
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Settings "Test" button calls login() on the shared cookidooClientProvider instance. That mutates the global in-memory auth token, so merely testing credentials (even then tapping Cancel) can leave the app authenticated and allow later get_recipe_detail calls to succeed unexpectedly. Use a short-lived client for testing, or ensure the test flow does not update/retain the shared client token.

Copilot uses AI. Check for mistakes.
Comment on lines +20 to +43
return ListTile(
leading: const Icon(Icons.cloud_outlined),
title: const Text('Cookidoo'),
subtitle: Text(subtitle),
onTap: () => _showCredentialsDialog(context, ref, credentials),
);
}

Future<void> _showCredentialsDialog(
BuildContext context,
WidgetRef ref,
CookidooCredentials? current,
) async {
final l10n = AppLocalizations.of(context);
final emailController =
TextEditingController(text: current?.email ?? '');
final passwordController =
TextEditingController(text: current?.password ?? '');

await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Cookidoo'),
content: Column(
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Cookidoo tile title and dialog title are hard-coded (Text('Cookidoo')) instead of using localization keys. Other settings tiles use AppLocalizations for titles/dialog titles (e.g. lib/features/recipe/presentation/dietary_restrictions_tile.dart:19,30). Use l10n.settingsSectionCookidoo (or a dedicated title key) to keep the Settings UI fully translated.

Copilot uses AI. Check for mistakes.
Comment on lines +44 to +58
try {
final results =
await _repository.searchRecipes(query, limit: limit);
final summaries = results
.map((r) => {
'id': r.id,
'title': r.title,
'rating': r.rating,
'totalTimeMinutes': r.totalTime ~/ 60,
})
.toList();
debugPrint(
'SearchRecipesHandler: ${results.length} results for "$query"'
'\n${jsonEncode(summaries)}',
);
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tool handlers only debugPrint the fetched data. The current tool framework (ToolHandler.execute returns Future<void> and ToolRegistry.handle just awaits it) does not feed tool outputs back into the model or into the conversation context, so the LLM can’t actually use Cookidoo search/detail results for “inspiration” as described. Consider extending the tool pipeline to return structured results (and inject them into the next model turn), or adjust the skill/feature expectations to match the current capabilities.

Copilot uses AI. Check for mistakes.
Comment on lines +80 to +82
final countryCode = CookidooClient.countryCodeFromLocale(
'${Localizations.localeOf(context).languageCode}-${Localizations.localeOf(context).countryCode ?? Localizations.localeOf(context).languageCode.toUpperCase()}',
);
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "Test" login is computing the locale string with the same languageCode.toUpperCase() fallback when countryCode is null, which can produce en-EN and map to countryCode="en" (invalid host). Reuse the same locale/country derivation logic as the repository (or explicitly handle missing country codes) so the test hits the correct Cookidoo environment.

Copilot uses AI. Check for mistakes.
Comment on lines +83 to +84
'grant_type=refresh_token&refresh_token=${_token!.refreshToken}'
'&client_id=$_clientId',
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The refresh-token request body is built as a raw string while declaring application/x-www-form-urlencoded. refresh_token (and client_id) should be URL-encoded; otherwise tokens containing +, &, or = can break the request payload.

Suggested change
'grant_type=refresh_token&refresh_token=${_token!.refreshToken}'
'&client_id=$_clientId',
'grant_type=refresh_token'
'&refresh_token=${Uri.encodeQueryComponent(_token!.refreshToken)}'
'&client_id=${Uri.encodeQueryComponent(_clientId)}',

Copilot uses AI. Check for mistakes.
Comment on lines +98 to +111
Future<void> _ensureAuth(
CookidooCredentials credentials, {
required String countryCode,
}) async {
if (_token == null) {
await login(credentials, countryCode: countryCode);
} else if (_token!.isExpired) {
try {
await _refreshToken(countryCode: countryCode);
} catch (_) {
await login(credentials, countryCode: countryCode);
}
}
}
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CookidooClient caches _token globally for the whole app, but it’s not bound to the credentials currently stored/entered. Because cookidooClientProvider returns a single long-lived client, tokens obtained from the Settings “Test” flow (or previous credentials) can be reused later even if the user changes/cancels credentials, which is a privacy/security issue. Consider scoping tokens per-credential (track the email used to obtain _token and clear on mismatch), and/or using a temporary client instance for the “Test” action so it can’t affect global auth state.

Copilot uses AI. Check for mistakes.
Comment on lines +83 to +84
debugPrint('Cookidoo test: countryCode=$countryCode, email=${testCreds.email}');
try {
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

debugPrint in the settings test flow logs the user’s email address to device logs. Avoid logging PII (or gate behind kDebugMode and redact) to reduce accidental exposure in production logs/crash reports.

Copilot uses AI. Check for mistakes.
using-system and others added 2 commits April 20, 2026 21:21
The repository now reads credentials at call time instead of at
construction time. This breaks the ref.watch chain that was causing
toolRegistryProvider to rebuild when credentials changed, which
triggered a .depends.isEmpty assertion in conversation_page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ild crash

Navigator.pop() must happen before setCredentials() to avoid the
framework _dependents.isEmpty assertion. The provider state change
was triggering a widget rebuild while the dialog was still disposing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 20, 2026 19:23
using-system and others added 2 commits April 20, 2026 21:25
… crash

Use storage.write + ref.invalidate instead of notifier.setCredentials
to prevent the provider state transition (loading→data) from triggering
a widget rebuild while the dialog route is still animating out.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…pletes

The overlay GlobalKeys conflict when the provider rebuild happens while
the dialog route is still animating out. Now we write to storage first,
await the dialog close animation, then invalidate the provider.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 25 out of 26 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


return ListTile(
leading: const Icon(Icons.cloud_outlined),
title: const Text('Cookidoo'),
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tile title is hard-coded ('Cookidoo') instead of using the localized string that was added (settingsSectionCookidoo). Other Settings tiles consistently use AppLocalizations for user-facing text, so this will remain untranslated in non-English locales.

Suggested change
title: const Text('Cookidoo'),
title: Text(l10n.settingsSectionCookidoo),

Copilot uses AI. Check for mistakes.
Comment on lines +41 to +43
builder: (ctx) => AlertDialog(
title: const Text('Cookidoo'),
content: Column(
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dialog title is hard-coded ('Cookidoo') and won’t be localized. Please use an existing localized key (e.g. settingsSectionCookidoo) or add a dedicated settingsCookidooDialogTitle key for consistency with other settings dialogs.

Copilot uses AI. Check for mistakes.
Future<void> execute(
Map<String, dynamic> args, BuildContext context) async {
final query = args['query'] as String? ?? '';
final limit = args['limit'] as int? ?? 5;
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

limit is read as args['limit'] as int?, which will throw if the JSON parser delivers a num (e.g., 5.0) instead of an int. To make the handler robust to tool-call JSON decoding, parse it as num? and convert to toInt() (and optionally clamp to a sensible range).

Suggested change
final limit = args['limit'] as int? ?? 5;
final limit = (args['limit'] as num?)?.toInt() ?? 5;

Copilot uses AI. Check for mistakes.
Comment on lines +16 to +21
Tool get definition => const Tool(
name: 'search_recipes',
description:
'Search for Thermomix recipes on Cookidoo. Returns a list of '
'matching recipes with title, rating, and total time.',
parameters: {
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tool description claims it "Returns a list of matching recipes", but ToolHandler.execute/ToolRegistry.handle don’t propagate any tool output back into the chat (they only execute side effects). As a result, the LLM can’t actually use the search results as context. Either wire tool results back into the model conversation (e.g., by injecting a hidden message with the JSON) or adjust the tool/skill copy so it doesn’t imply return values the model will see.

Copilot uses AI. Check for mistakes.
using-system and others added 2 commits April 20, 2026 21:30
…ebuild crash

Replace ConsumerWidget + ref.watch on async provider with a
ConsumerStatefulWidget that manages credentials locally via setState.
The dialog now returns the credentials via Navigator.pop result and
the save happens after the dialog is fully closed — no provider
invalidation, no rebuild cascade, no overlay GlobalKey conflict.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… crash

The ConsumerState/ConsumerWidget creates InheritedWidget dependencies
that conflict with dialog overlay disposal, causing _dependents.isEmpty
assertion. Replaced with plain StatefulWidget using SharedPreferences
directly — zero Riverpod involvement in this widget.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 20, 2026 19:36
using-system and others added 2 commits April 20, 2026 21:39
Use the same ConsumerWidget + showDialog + write-after-pop pattern
that works in DietaryRestrictionsTile. Watch sharedPreferencesProvider
for the subtitle, write directly to prefs after dialog closes. No
provider invalidation — tile updates on next Settings visit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…Android

AutofillHints.email and AutofillHints.password create platform
autofill InheritedWidgets that fail to clean up dependents when
the dialog is dismissed, triggering _dependents.isEmpty assertion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 25 out of 26 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +107 to +110
emailController.dispose();
passwordController.dispose();

if (result != null) {
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The locale→countryCode derivation here has the same issue as in the repository provider: when locale.countryCode is null (forced language selection), it generates e.g. en-EN, which then becomes en and may hit the wrong Cookidoo host. Reuse the same language→default-country mapping logic as the repository so “Test” behaves consistently with actual API calls.

Copilot uses AI. Check for mistakes.
Comment on lines +112 to +116
await prefs.setString(_keyPassword, result.password);
}
},
);
}
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CookidooClient() is instantiated inside the dialog’s “Test” button handler and never disposed/closed. Because CookidooClient owns an http.Client, this can leak sockets/resources across repeated tests. Prefer reusing the app’s provided CookidooClient (via provider) or ensure the temporary client is disposed after the request.

Copilot uses AI. Check for mistakes.
try {
await client.login(creds, countryCode: _countryCode);
return true;
} on CookidooAuthException {
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isAuthenticated() returns bool but only catches CookidooAuthException. Network failures (e.g. no connectivity) will still throw (from http.Client) and could crash callers that expect a boolean. Either catch broader exceptions and return false, or convert transport errors into CookidooNetworkException so callers can handle them explicitly.

Suggested change
} on CookidooAuthException {
} on Exception {

Copilot uses AI. Check for mistakes.
Comment on lines +29 to +46
if (prefs == null) return;

final currentPassword = prefs.getString(_keyPassword) ?? '';
final emailController = TextEditingController(text: email);
final passwordController =
TextEditingController(text: currentPassword);

final result = await showDialog<CookidooCredentials>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Cookidoo'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: emailController,
decoration: InputDecoration(
labelText: l10n.settingsCookidooEmailTitle,
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This tile reads/writes cookidoo_email / cookidoo_password directly via SharedPreferences.getInstance() and keeps its own _email/_password state. That bypasses the new CookidooCredentialsStorage + cookidooCredentialsProvider, so the repository/tooling won’t observe credential updates until restart (and the keys are duplicated in two places). Refactor this to use the Riverpod credential provider/notifier for loading/saving, and reuse the shared storage keys from the storage layer to avoid drift.

Copilot uses AI. Check for mistakes.
using-system and others added 2 commits April 20, 2026 21:43
TextFields inside dialogs shown from a StatefulShellRoute branch
crash with _dependents.isEmpty because the shell's nested Overlay
conflicts with the dialog's Overlay. Using useRootNavigator: true
ensures the dialog is shown on the root navigator's overlay instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 20, 2026 19:45
Same fix as cookidoo credentials tile — disposing a
TextEditingController while the dialog exit animation is still
running causes _dependents.isEmpty assertion on Android.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 26 out of 27 changed files in this pull request and generated 9 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +135 to +138
final image = json['image'] as String? ??
(json['descriptiveAssets'] as List<dynamic>?)
?.firstOrNull
?['square'] as String? ??
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

firstOrNull is used here but this file has no imports, so the extension method won’t be in scope and the file won’t compile. Either add the required import (where the firstOrNull extension is defined) or rewrite this to avoid firstOrNull (e.g., check isNotEmpty and use first).

Suggested change
final image = json['image'] as String? ??
(json['descriptiveAssets'] as List<dynamic>?)
?.firstOrNull
?['square'] as String? ??
final descriptiveAssets = json['descriptiveAssets'] as List<dynamic>?;
final image = json['image'] as String? ??
(descriptiveAssets != null && descriptiveAssets.isNotEmpty
? (descriptiveAssets.first as Map<String, dynamic>)['square']
as String?
: null) ??

Copilot uses AI. Check for mistakes.
Comment on lines +38 to +45
Future<CookidooRecipeDetail> getRecipeDetail(String recipeId) {
final creds = credentials;
if (creds == null || creds.isEmpty) {
throw const CookidooAuthException(
'Cookidoo credentials not configured',
);
}
return client.getRecipeDetail(
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getRecipeDetail throws CookidooAuthException synchronously in a non-async method that returns Future. That throws immediately instead of returning a failed Future, which can break callers that await/catch asynchronously. Make the method async and throw, or return Future.error(...).

Suggested change
Future<CookidooRecipeDetail> getRecipeDetail(String recipeId) {
final creds = credentials;
if (creds == null || creds.isEmpty) {
throw const CookidooAuthException(
'Cookidoo credentials not configured',
);
}
return client.getRecipeDetail(
Future<CookidooRecipeDetail> getRecipeDetail(String recipeId) async {
final creds = credentials;
if (creds == null || creds.isEmpty) {
throw const CookidooAuthException(
'Cookidoo credentials not configured',
);
}
return await client.getRecipeDetail(

Copilot uses AI. Check for mistakes.
Comment on lines +108 to +111
if (result != null) {
await prefs.setString(_keyEmail, result.email);
await prefs.setString(_keyPassword, result.password);
}
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Credentials are persisted via SharedPreferences here, but the Cookidoo feature layer reads credentials from cookidooCredentialsProvider. Saving here won’t update that provider state, so the repository/tool handlers can keep seeing empty credentials until restart. Persist via cookidooCredentialsProvider.notifier.setCredentials(...) (and read current values from the provider) to keep UI and repository in sync.

Copilot uses AI. Check for mistakes.
Comment on lines +80 to +84
try {
await CookidooClient().login(
testCreds,
countryCode: countryCode,
);
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The “Test” action instantiates a new CookidooClient() but never calls dispose(), leaking an http.Client. Reuse the Riverpod-provided CookidooClient (or repository) and ensure the client is disposed via provider lifecycle, or explicitly close it after the test call.

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +26
return ListTile(
leading: const Icon(Icons.cloud_outlined),
title: const Text('Cookidoo'),
subtitle: Text(subtitle),
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This title is hard-coded instead of using the newly added Cookidoo localization keys. Use l10n.settingsSectionCookidoo (and the same for the dialog title) so the UI is localized consistently across locales.

Copilot uses AI. Check for mistakes.
Comment on lines +37 to +41
Future<CookidooAuthToken> login(
CookidooCredentials credentials, {
required String countryCode,
}) async {
final url =
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cookidoo client/auth flow and JSON parsing are introduced here without automated tests. Consider adding unit tests with a mocked http.Client for login/token refresh (and non-200 paths) to prevent regressions.

Copilot uses AI. Check for mistakes.

if (response.statusCode != 200) {
debugPrint('Cookidoo login: POST $url → ${response.statusCode}');
debugPrint('Cookidoo login response: ${response.body}');
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logs the raw HTTP response body on auth failure. Auth error payloads can include sensitive details; consider removing the body log or guarding it behind a debug-only flag with redaction to reduce risk of leaking data into logs.

Suggested change
debugPrint('Cookidoo login response: ${response.body}');

Copilot uses AI. Check for mistakes.
Comment on lines +36 to +110
final result = await showDialog<CookidooCredentials>(
context: context,
useRootNavigator: true,
builder: (ctx) => AlertDialog(
title: const Text('Cookidoo'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: emailController,
decoration: InputDecoration(
labelText: l10n.settingsCookidooEmailTitle,
hintText: l10n.settingsCookidooEmailHint,
),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
TextField(
controller: passwordController,
decoration: InputDecoration(
labelText: l10n.settingsCookidooPasswordTitle,
hintText: l10n.settingsCookidooPasswordHint,
),
obscureText: true,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () async {
final testCreds = CookidooCredentials(
email: emailController.text.trim(),
password: passwordController.text,
);
final locale = Localizations.localeOf(context);
final countryCode =
CookidooClient.countryCodeFromLocale(
'${locale.languageCode}-${locale.countryCode ?? locale.languageCode.toUpperCase()}',
);
final messenger = ScaffoldMessenger.of(context);
try {
await CookidooClient().login(
testCreds,
countryCode: countryCode,
);
messenger.showSnackBar(SnackBar(
content:
Text(l10n.settingsCookidooTestSuccess)));
} catch (e) {
debugPrint('Cookidoo test login failed: $e');
messenger.showSnackBar(SnackBar(
content:
Text(l10n.settingsCookidooTestFailure)));
}
},
child: Text(l10n.settingsCookidooTest),
),
TextButton(
onPressed: () =>
Navigator.of(ctx).pop(CookidooCredentials(
email: emailController.text.trim(),
password: passwordController.text,
)),
child: Text(l10n.ok),
),
],
),
);
if (result != null) {
await prefs.setString(_keyEmail, result.email);
await prefs.setString(_keyPassword, result.password);
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TextEditingControllers are created but never disposed. Dispose them after the dialog completes to avoid leaks (see how other settings tiles dispose controllers after showDialog).

Suggested change
final result = await showDialog<CookidooCredentials>(
context: context,
useRootNavigator: true,
builder: (ctx) => AlertDialog(
title: const Text('Cookidoo'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: emailController,
decoration: InputDecoration(
labelText: l10n.settingsCookidooEmailTitle,
hintText: l10n.settingsCookidooEmailHint,
),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
TextField(
controller: passwordController,
decoration: InputDecoration(
labelText: l10n.settingsCookidooPasswordTitle,
hintText: l10n.settingsCookidooPasswordHint,
),
obscureText: true,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () async {
final testCreds = CookidooCredentials(
email: emailController.text.trim(),
password: passwordController.text,
);
final locale = Localizations.localeOf(context);
final countryCode =
CookidooClient.countryCodeFromLocale(
'${locale.languageCode}-${locale.countryCode ?? locale.languageCode.toUpperCase()}',
);
final messenger = ScaffoldMessenger.of(context);
try {
await CookidooClient().login(
testCreds,
countryCode: countryCode,
);
messenger.showSnackBar(SnackBar(
content:
Text(l10n.settingsCookidooTestSuccess)));
} catch (e) {
debugPrint('Cookidoo test login failed: $e');
messenger.showSnackBar(SnackBar(
content:
Text(l10n.settingsCookidooTestFailure)));
}
},
child: Text(l10n.settingsCookidooTest),
),
TextButton(
onPressed: () =>
Navigator.of(ctx).pop(CookidooCredentials(
email: emailController.text.trim(),
password: passwordController.text,
)),
child: Text(l10n.ok),
),
],
),
);
if (result != null) {
await prefs.setString(_keyEmail, result.email);
await prefs.setString(_keyPassword, result.password);
try {
final result = await showDialog<CookidooCredentials>(
context: context,
useRootNavigator: true,
builder: (ctx) => AlertDialog(
title: const Text('Cookidoo'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: emailController,
decoration: InputDecoration(
labelText: l10n.settingsCookidooEmailTitle,
hintText: l10n.settingsCookidooEmailHint,
),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
TextField(
controller: passwordController,
decoration: InputDecoration(
labelText: l10n.settingsCookidooPasswordTitle,
hintText: l10n.settingsCookidooPasswordHint,
),
obscureText: true,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () async {
final testCreds = CookidooCredentials(
email: emailController.text.trim(),
password: passwordController.text,
);
final locale = Localizations.localeOf(context);
final countryCode =
CookidooClient.countryCodeFromLocale(
'${locale.languageCode}-${locale.countryCode ?? locale.languageCode.toUpperCase()}',
);
final messenger = ScaffoldMessenger.of(context);
try {
await CookidooClient().login(
testCreds,
countryCode: countryCode,
);
messenger.showSnackBar(SnackBar(
content:
Text(l10n.settingsCookidooTestSuccess)));
} catch (e) {
debugPrint('Cookidoo test login failed: $e');
messenger.showSnackBar(SnackBar(
content:
Text(l10n.settingsCookidooTestFailure)));
}
},
child: Text(l10n.settingsCookidooTest),
),
TextButton(
onPressed: () =>
Navigator.of(ctx).pop(CookidooCredentials(
email: emailController.text.trim(),
password: passwordController.text,
)),
child: Text(l10n.ok),
),
],
),
);
if (result != null) {
await prefs.setString(_keyEmail, result.email);
await prefs.setString(_keyPassword, result.password);
}
} finally {
emailController.dispose();
passwordController.dispose();

Copilot uses AI. Check for mistakes.
Comment on lines +19 to +20
'Get the full details of a Cookidoo recipe by ID, including '
'ingredients, steps, and nutrition information.',
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tool description implies the model will receive full recipe details, but the handler only debugPrints them and doesn’t return/record output in conversation context. Either adjust the tool description, or propagate a structured tool-result back into the chat flow so the LLM can incorporate the data.

Suggested change
'Get the full details of a Cookidoo recipe by ID, including '
'ingredients, steps, and nutrition information.',
'Fetch a Cookidoo recipe by ID for app-side handling. This '
'handler retrieves recipe data such as title, ingredients, and '
'steps when available.',

Copilot uses AI. Check for mistakes.
using-system and others added 2 commits April 20, 2026 22:02
Prefix all logs with >>> for easy filtering in flutter run console.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ToolHandler.execute now returns Map<String, dynamic>? instead of void.
Tool results are sent back to the model via Message.toolResponse +
addQueryChunk so the LLM can use Cookidoo search results as context
for generating recipes. Fire-and-forget tools (share) return null.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 20, 2026 20:14
After the initial stream ends with a function call, the tool results
are sent back via Message.toolResponse, then a second
generateChatResponseAsync is triggered so the LLM produces its final
answer using the Cookidoo search results as context.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 30 out of 31 changed files in this pull request and generated 6 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 26 to 27
final controller = TextEditingController(text: current);
final result = await showDialog<String>(
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TextEditingController created for the dialog is never disposed. Since this tile can be opened multiple times, this can leak resources. Dispose the controller in a finally block after showDialog completes (or use a StatefulBuilder/TextField with onChanged and no controller).

Copilot uses AI. Check for mistakes.
return {'error': 'Recipe $recipeId not found'};
} on CookidooNetworkException catch (e) {
debugPrint('>>> GetRecipeDetailHandler: network error — $e');
return {'error': 'Network error: $e'};
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This handler only catches a few Cookidoo exception types. Other failures (e.g., JSON parsing errors, unexpected API payloads) will bubble up and can break the chat/tool stream. Add a final catch to return a structured error response (and log) so the LLM can recover gracefully.

Suggested change
return {'error': 'Network error: $e'};
return {'error': 'Network error: $e'};
} catch (e, stackTrace) {
debugPrint(
'>>> GetRecipeDetailHandler: unexpected error for recipe $recipeId — $e');
debugPrint('>>> GetRecipeDetailHandler stackTrace: $stackTrace');
return {'error': 'Unexpected error while fetching recipe details'};

Copilot uses AI. Check for mistakes.
Comment on lines +35 to +107

final result = await showDialog<CookidooCredentials>(
context: context,
useRootNavigator: true,
builder: (ctx) => AlertDialog(
title: const Text('Cookidoo'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: emailController,
decoration: InputDecoration(
labelText: l10n.settingsCookidooEmailTitle,
hintText: l10n.settingsCookidooEmailHint,
),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
TextField(
controller: passwordController,
decoration: InputDecoration(
labelText: l10n.settingsCookidooPasswordTitle,
hintText: l10n.settingsCookidooPasswordHint,
),
obscureText: true,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () async {
final testCreds = CookidooCredentials(
email: emailController.text.trim(),
password: passwordController.text,
);
final locale = Localizations.localeOf(context);
final countryCode =
CookidooClient.countryCodeFromLocale(
'${locale.languageCode}-${locale.countryCode ?? locale.languageCode.toUpperCase()}',
);
final messenger = ScaffoldMessenger.of(context);
try {
await CookidooClient().login(
testCreds,
countryCode: countryCode,
);
messenger.showSnackBar(SnackBar(
content:
Text(l10n.settingsCookidooTestSuccess)));
} catch (e) {
debugPrint('Cookidoo test login failed: $e');
messenger.showSnackBar(SnackBar(
content:
Text(l10n.settingsCookidooTestFailure)));
}
},
child: Text(l10n.settingsCookidooTest),
),
TextButton(
onPressed: () =>
Navigator.of(ctx).pop(CookidooCredentials(
email: emailController.text.trim(),
password: passwordController.text,
)),
child: Text(l10n.ok),
),
],
),
);
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

emailController and passwordController are created on tap but never disposed. Dispose both after the dialog completes to avoid leaking controllers when the credentials dialog is opened repeatedly.

Suggested change
final result = await showDialog<CookidooCredentials>(
context: context,
useRootNavigator: true,
builder: (ctx) => AlertDialog(
title: const Text('Cookidoo'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: emailController,
decoration: InputDecoration(
labelText: l10n.settingsCookidooEmailTitle,
hintText: l10n.settingsCookidooEmailHint,
),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
TextField(
controller: passwordController,
decoration: InputDecoration(
labelText: l10n.settingsCookidooPasswordTitle,
hintText: l10n.settingsCookidooPasswordHint,
),
obscureText: true,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () async {
final testCreds = CookidooCredentials(
email: emailController.text.trim(),
password: passwordController.text,
);
final locale = Localizations.localeOf(context);
final countryCode =
CookidooClient.countryCodeFromLocale(
'${locale.languageCode}-${locale.countryCode ?? locale.languageCode.toUpperCase()}',
);
final messenger = ScaffoldMessenger.of(context);
try {
await CookidooClient().login(
testCreds,
countryCode: countryCode,
);
messenger.showSnackBar(SnackBar(
content:
Text(l10n.settingsCookidooTestSuccess)));
} catch (e) {
debugPrint('Cookidoo test login failed: $e');
messenger.showSnackBar(SnackBar(
content:
Text(l10n.settingsCookidooTestFailure)));
}
},
child: Text(l10n.settingsCookidooTest),
),
TextButton(
onPressed: () =>
Navigator.of(ctx).pop(CookidooCredentials(
email: emailController.text.trim(),
password: passwordController.text,
)),
child: Text(l10n.ok),
),
],
),
);
CookidooCredentials? result;
try {
result = await showDialog<CookidooCredentials>(
context: context,
useRootNavigator: true,
builder: (ctx) => AlertDialog(
title: const Text('Cookidoo'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: emailController,
decoration: InputDecoration(
labelText: l10n.settingsCookidooEmailTitle,
hintText: l10n.settingsCookidooEmailHint,
),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
TextField(
controller: passwordController,
decoration: InputDecoration(
labelText: l10n.settingsCookidooPasswordTitle,
hintText: l10n.settingsCookidooPasswordHint,
),
obscureText: true,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () async {
final testCreds = CookidooCredentials(
email: emailController.text.trim(),
password: passwordController.text,
);
final locale = Localizations.localeOf(context);
final countryCode =
CookidooClient.countryCodeFromLocale(
'${locale.languageCode}-${locale.countryCode ?? locale.languageCode.toUpperCase()}',
);
final messenger = ScaffoldMessenger.of(context);
try {
await CookidooClient().login(
testCreds,
countryCode: countryCode,
);
messenger.showSnackBar(SnackBar(
content:
Text(l10n.settingsCookidooTestSuccess)));
} catch (e) {
debugPrint('Cookidoo test login failed: $e');
messenger.showSnackBar(SnackBar(
content:
Text(l10n.settingsCookidooTestFailure)));
}
},
child: Text(l10n.settingsCookidooTest),
),
TextButton(
onPressed: () =>
Navigator.of(ctx).pop(CookidooCredentials(
email: emailController.text.trim(),
password: passwordController.text,
)),
child: Text(l10n.ok),
),
],
),
);
} finally {
emailController.dispose();
passwordController.dispose();
}

Copilot uses AI. Check for mistakes.
Comment on lines +45 to +46
debugPrint(
'>>> ToolRegistry.handle: "${response.name}" args=${response.args}');
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

debugPrint logs the full tool args. Tool args can include user/LLM-generated content (e.g., share payloads), which can leak potentially sensitive data into logs. Consider gating this behind kDebugMode, redacting large fields, or logging only the tool name / arg keys.

Suggested change
debugPrint(
'>>> ToolRegistry.handle: "${response.name}" args=${response.args}');
assert(() {
debugPrint(
'>>> ToolRegistry.handle: "${response.name}" '
'argKeys=${response.args.keys.toList()}',
);
return true;
}());

Copilot uses AI. Check for mistakes.
Comment on lines +39 to +41
final query = args['query'] as String? ?? '';
final limit = args['limit'] as int? ?? 5;

Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

limit is read as args['limit'] as int?, but JSON numbers may come through as double/num, which will cause a cast error and return an error instead of results. Parse limit as num? and convert to int, and consider validating/clamping (e.g., limit > 0) and rejecting empty query early with a clear error response.

Suggested change
final query = args['query'] as String? ?? '';
final limit = args['limit'] as int? ?? 5;
final query = (args['query'] as String? ?? '').trim();
final limitValue = args['limit'] as num?;
final limit = limitValue?.toInt() ?? 5;
if (query.isEmpty) {
debugPrint('>>> SearchRecipesHandler: empty query');
return {'error': 'Query must not be empty.'};
}
if (limit <= 0) {
debugPrint('>>> SearchRecipesHandler: invalid limit=$limit');
return {'error': 'Limit must be greater than 0.'};
}

Copilot uses AI. Check for mistakes.
Comment on lines +16 to +17
Future<Map<String, dynamic>?> execute(
Map<String, dynamic> args, BuildContext context);
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This signature change requires updating existing tool tests (e.g. test/features/tools/tool_registry_test.dart still implements Future<void> execute(...) and will no longer compile). Please update the test fake handler and expectations to match the new Future<Map<String, dynamic>?> contract.

Copilot uses AI. Check for mistakes.
using-system and others added 2 commits April 20, 2026 22:20
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The LLM must always call search_recipes before answering and base
its recipe on Cookidoo results rather than generating from scratch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 20, 2026 20:27
Add explicit instructions in the base system prompt to always use
search_recipes before answering and base recipes on search results.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 31 out of 32 changed files in this pull request and generated 8 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +48 to +50
if (handler == null) {
debugPrint('>>> ToolRegistry: no handler for "${response.name}"');
return null;
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When no handler is registered, handle returns null. In ConversationPage that means no toolResponse is sent back to the LLM, which can cause it to stall/retry because it never receives a tool result. Consider returning an error result map (e.g., {error: 'Unknown tool'}) so the caller can always send a toolResponse for any FunctionCallResponse.

Copilot uses AI. Check for mistakes.
Comment on lines +593 to 613
// After stream ends, if a tool was called, re-generate so the LLM
// produces its final answer using the tool results as context.
if (_hadToolCall && mounted && _chat != null) {
debugPrint('>>> Re-generating after tool call...');
int tokenCount = 0;
await for (final response in _chat!.generateChatResponseAsync()) {
if (!mounted) break;
debugPrint('>>> Re-gen response: ${response.runtimeType}');
if (response is TextResponse) {
tokenCount++;
buffer.write(response.token);
final elapsed = DateTime.now().difference(lastUpdate);
if (mounted && elapsed >= throttle) {
lastUpdate = DateTime.now();
_streamStates.set(
streamId, StreamStateStreaming(buffer.toString()));
}
} else if (response is FunctionCallResponse) {
debugPrint('>>> Re-gen: LLM called another tool: ${response.name}');
}
}
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

During the post-tool “re-generate” pass, FunctionCallResponses are only logged and not executed / responded to. If the model issues another tool call (common in multi-step tool use), it will never receive a toolResponse, and the final answer may be empty or the model may loop. Consider reusing the same tool-dispatch path here (with a max-iterations guard) so tool calls are always handled until the model returns text-only output.

Copilot uses AI. Check for mistakes.
Comment on lines 26 to 31
final controller = TextEditingController(text: current);
final result = await showDialog<String>(
context: context,
useRootNavigator: true,
builder: (ctx) => AlertDialog(
title: Text(l10n.settingsDietaryRestrictionsDialogTitle),
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TextEditingController is no longer disposed after the dialog closes. This can leak resources if the user opens this dialog multiple times. Dispose the controller in a finally block after showDialog completes.

Copilot uses AI. Check for mistakes.
Comment on lines +16 to +28
After receiving search results, base your recipe on the Cookidoo results.
Pick the best matching recipe and adapt it to the user's settings (portions, dietary restrictions, Thermomix version, difficulty level).

If Cookidoo credentials are configured, call `get_recipe_detail` on the most relevant result to get the full ingredients and steps:

- recipe_id: the Cookidoo recipe ID from search results (e.g. "r145192"). String.

When you have the full recipe detail, use it as the base for your answer. Adapt the format, language, and portions but keep the ingredients and steps faithful to the original.

## Guidelines

- ALWAYS search before answering a recipe request. No exceptions.
- Base your recipe on the search results. Do not invent recipes.
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The skill instructions say “keep the ingredients and steps faithful to the original” and “Do not invent recipes”, which conflicts with the PR description’s goal of using Cookidoo only as inspiration (not verbatim copy). This also increases the risk of reproducing copyrighted content. Consider rewording to explicitly summarize/adapt and avoid verbatim reproduction, especially for full ingredients/steps returned by get_recipe_detail.

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +26
return ListTile(
leading: const Icon(Icons.cloud_outlined),
title: const Text('Cookidoo'),
subtitle: Text(subtitle),
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tile title is hard-coded as 'Cookidoo' instead of using localized strings (e.g., other settings tiles use l10n.settings..., see ModelPickerTile). Consider using l10n.settingsSectionCookidoo (or adding a dedicated key) for the tile/dialog title so it’s translated consistently.

Copilot uses AI. Check for mistakes.
Comment on lines +43 to +53
final response = await _http.post(
url,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': _basicAuth,
},
body:
'grant_type=password&username=${Uri.encodeComponent(credentials.email)}'
'&password=${Uri.encodeComponent(credentials.password)}',
);
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HTTP calls here don’t set any timeout. A stalled connection can hang the tool call indefinitely and block the chat stream/UI. Consider applying a reasonable .timeout(...) to requests and translating TimeoutException into CookidooNetworkException so callers can handle it gracefully.

Copilot uses AI. Check for mistakes.
Comment on lines +18 to +20
static const _basicAuth =
'Basic a3VwZmVyd2Vyay1jbGllbnQtbndvdDpMczUwT04xd295U3FzMWRDZEpnZQ==';
static const _clientId = 'kupferwerk-client-nwot';
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_basicAuth appears to embed a static client credential (Basic auth header). Hard-coding this in the app makes it trivially extractable and may violate the API provider’s terms. If this value is meant to be secret, move auth/token exchange to a backend or use an official/public client mechanism; at minimum, document that this is a non-secret public identifier and confirm it’s safe to ship.

Copilot uses AI. Check for mistakes.
Comment on lines +9 to +10
const _keyEmail = 'cookidoo_email';
const _keyPassword = 'cookidoo_password';
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These SharedPreferences keys are duplicated here and also in CookidooCredentialsStorage. Duplicating string keys risks drift over time; consider reusing the storage class (or exporting the keys) so the UI and repository persist/read the same fields consistently.

Copilot uses AI. Check for mistakes.
using-system and others added 2 commits April 21, 2026 08:52
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add `tools` field to SKILL.md frontmatter to declare associated tools
- Add SkillPreferencesStorage for persisting skill enabled state
- Add Skills section in Settings with SwitchListTile per skill
- Filter tool registry to only load handlers for enabled skills
- Revert system prompt to minimal (skill-specific instructions come
  from SKILL.md files only)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 21, 2026 06:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants