feat(cookidoo): add Cookidoo recipe search integration#31
feat(cookidoo): add Cookidoo recipe search integration#31using-system wants to merge 32 commits intomainfrom
Conversation
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>
There was a problem hiding this comment.
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 newsearch-recipeskill asset. - Added Cookidoo credentials UI in Settings and localized strings across en/fr/de/es; added
httpdependency.
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.
| 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, | ||
| ), | ||
| ), | ||
| ); |
There was a problem hiding this comment.
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.
| 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: ''), | |
| ); | |
| } |
| builder: (ctx) => AlertDialog( | ||
| title: const Text('Cookidoo'), | ||
| content: Column( |
There was a problem hiding this comment.
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.
| final response = await _http.get(url, headers: { | ||
| 'Accept': 'application/json', | ||
| }); | ||
|
|
||
| if (response.statusCode != 200) { | ||
| throw CookidooNetworkException( | ||
| 'Search failed (${response.statusCode})', | ||
| ); | ||
| } |
There was a problem hiding this comment.
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.
| debugPrint( | ||
| 'GetRecipeDetailHandler: ${detail.title}' | ||
| '\nIngredients: ${jsonEncode(ingredients)}' | ||
| '\nSteps: ${jsonEncode(steps)}', | ||
| ); |
There was a problem hiding this comment.
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).
| 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. | ||
|
|
There was a problem hiding this comment.
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.
| kcal = number; | ||
| case 'protein': | ||
| protein = number; | ||
| case 'fat': | ||
| fat = number; | ||
| case 'carbohydrates': | ||
| carbs = number; |
There was a problem hiding this comment.
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.
| 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; |
| return ListTile( | ||
| leading: const Icon(Icons.cloud_outlined), | ||
| title: const Text('Cookidoo'), | ||
| subtitle: Text(subtitle), | ||
| onTap: () => _showCredentialsDialog(context, ref, credentials), |
There was a problem hiding this comment.
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.
| 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()}'; |
There was a problem hiding this comment.
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.
| 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); |
| debugPrint( | ||
| 'SearchRecipesHandler: ${results.length} results for "$query"' | ||
| '\n${jsonEncode(summaries)}', | ||
| ); |
There was a problem hiding this comment.
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).
|
|
||
| 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); |
There was a problem hiding this comment.
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.
| 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); |
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>
There was a problem hiding this comment.
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.
| 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); | ||
| } |
There was a problem hiding this comment.
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.
| debugPrint('Cookidoo login: POST $url → ${response.statusCode}'); | ||
| debugPrint('Cookidoo login response: ${response.body}'); |
There was a problem hiding this comment.
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.
| debugPrint('Cookidoo login: POST $url → ${response.statusCode}'); | |
| debugPrint('Cookidoo login response: ${response.body}'); | |
| if (kDebugMode) { | |
| debugPrint('Cookidoo login: POST $url → ${response.statusCode}'); | |
| } |
| if (response.statusCode != 200) { | ||
| debugPrint('Cookidoo login: POST $url → ${response.statusCode}'); | ||
| debugPrint('Cookidoo login response: ${response.body}'); | ||
| throw CookidooAuthException( | ||
| 'Login failed (${response.statusCode})', | ||
| ); |
There was a problem hiding this comment.
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).
| 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( |
There was a problem hiding this comment.
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.
| 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( |
There was a problem hiding this comment.
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.
| 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)}', | ||
| ); |
There was a problem hiding this comment.
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.
| final countryCode = CookidooClient.countryCodeFromLocale( | ||
| '${Localizations.localeOf(context).languageCode}-${Localizations.localeOf(context).countryCode ?? Localizations.localeOf(context).languageCode.toUpperCase()}', | ||
| ); |
There was a problem hiding this comment.
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.
| 'grant_type=refresh_token&refresh_token=${_token!.refreshToken}' | ||
| '&client_id=$_clientId', |
There was a problem hiding this comment.
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.
| '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)}', |
| 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); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| debugPrint('Cookidoo test: countryCode=$countryCode, email=${testCreds.email}'); | ||
| try { |
There was a problem hiding this comment.
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.
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>
… 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>
There was a problem hiding this comment.
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'), |
There was a problem hiding this comment.
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.
| title: const Text('Cookidoo'), | |
| title: Text(l10n.settingsSectionCookidoo), |
| builder: (ctx) => AlertDialog( | ||
| title: const Text('Cookidoo'), | ||
| content: Column( |
There was a problem hiding this comment.
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.
| Future<void> execute( | ||
| Map<String, dynamic> args, BuildContext context) async { | ||
| final query = args['query'] as String? ?? ''; | ||
| final limit = args['limit'] as int? ?? 5; |
There was a problem hiding this comment.
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).
| final limit = args['limit'] as int? ?? 5; | |
| final limit = (args['limit'] as num?)?.toInt() ?? 5; |
| 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: { |
There was a problem hiding this comment.
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.
…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>
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>
There was a problem hiding this comment.
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.
| emailController.dispose(); | ||
| passwordController.dispose(); | ||
|
|
||
| if (result != null) { |
There was a problem hiding this comment.
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.
| await prefs.setString(_keyPassword, result.password); | ||
| } | ||
| }, | ||
| ); | ||
| } |
There was a problem hiding this comment.
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.
| try { | ||
| await client.login(creds, countryCode: _countryCode); | ||
| return true; | ||
| } on CookidooAuthException { |
There was a problem hiding this comment.
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.
| } on CookidooAuthException { | |
| } on Exception { |
| 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, |
There was a problem hiding this comment.
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.
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>
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>
There was a problem hiding this comment.
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.
| final image = json['image'] as String? ?? | ||
| (json['descriptiveAssets'] as List<dynamic>?) | ||
| ?.firstOrNull | ||
| ?['square'] as String? ?? |
There was a problem hiding this comment.
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).
| 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) ?? |
| Future<CookidooRecipeDetail> getRecipeDetail(String recipeId) { | ||
| final creds = credentials; | ||
| if (creds == null || creds.isEmpty) { | ||
| throw const CookidooAuthException( | ||
| 'Cookidoo credentials not configured', | ||
| ); | ||
| } | ||
| return client.getRecipeDetail( |
There was a problem hiding this comment.
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(...).
| 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( |
| if (result != null) { | ||
| await prefs.setString(_keyEmail, result.email); | ||
| await prefs.setString(_keyPassword, result.password); | ||
| } |
There was a problem hiding this comment.
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.
| try { | ||
| await CookidooClient().login( | ||
| testCreds, | ||
| countryCode: countryCode, | ||
| ); |
There was a problem hiding this comment.
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.
| return ListTile( | ||
| leading: const Icon(Icons.cloud_outlined), | ||
| title: const Text('Cookidoo'), | ||
| subtitle: Text(subtitle), |
There was a problem hiding this comment.
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.
| Future<CookidooAuthToken> login( | ||
| CookidooCredentials credentials, { | ||
| required String countryCode, | ||
| }) async { | ||
| final url = |
There was a problem hiding this comment.
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.
|
|
||
| if (response.statusCode != 200) { | ||
| debugPrint('Cookidoo login: POST $url → ${response.statusCode}'); | ||
| debugPrint('Cookidoo login response: ${response.body}'); |
There was a problem hiding this comment.
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.
| debugPrint('Cookidoo login response: ${response.body}'); |
| 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); |
There was a problem hiding this comment.
TextEditingControllers are created but never disposed. Dispose them after the dialog completes to avoid leaks (see how other settings tiles dispose controllers after showDialog).
| 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(); |
| 'Get the full details of a Cookidoo recipe by ID, including ' | ||
| 'ingredients, steps, and nutrition information.', |
There was a problem hiding this comment.
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.
| '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.', |
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>
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>
There was a problem hiding this comment.
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.
| final controller = TextEditingController(text: current); | ||
| final result = await showDialog<String>( |
There was a problem hiding this comment.
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).
| return {'error': 'Recipe $recipeId not found'}; | ||
| } on CookidooNetworkException catch (e) { | ||
| debugPrint('>>> GetRecipeDetailHandler: network error — $e'); | ||
| return {'error': 'Network error: $e'}; |
There was a problem hiding this comment.
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.
| 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'}; |
|
|
||
| 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), | ||
| ), | ||
| ], | ||
| ), | ||
| ); |
There was a problem hiding this comment.
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.
| 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(); | |
| } |
| debugPrint( | ||
| '>>> ToolRegistry.handle: "${response.name}" args=${response.args}'); |
There was a problem hiding this comment.
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.
| debugPrint( | |
| '>>> ToolRegistry.handle: "${response.name}" args=${response.args}'); | |
| assert(() { | |
| debugPrint( | |
| '>>> ToolRegistry.handle: "${response.name}" ' | |
| 'argKeys=${response.args.keys.toList()}', | |
| ); | |
| return true; | |
| }()); |
| final query = args['query'] as String? ?? ''; | ||
| final limit = args['limit'] as int? ?? 5; | ||
|
|
There was a problem hiding this comment.
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.
| 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.'}; | |
| } |
| Future<Map<String, dynamic>?> execute( | ||
| Map<String, dynamic> args, BuildContext context); |
There was a problem hiding this comment.
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.
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>
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>
There was a problem hiding this comment.
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.
| if (handler == null) { | ||
| debugPrint('>>> ToolRegistry: no handler for "${response.name}"'); | ||
| return null; |
There was a problem hiding this comment.
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.
| // 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}'); | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| final controller = TextEditingController(text: current); | ||
| final result = await showDialog<String>( | ||
| context: context, | ||
| useRootNavigator: true, | ||
| builder: (ctx) => AlertDialog( | ||
| title: Text(l10n.settingsDietaryRestrictionsDialogTitle), |
There was a problem hiding this comment.
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.
| 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. |
There was a problem hiding this comment.
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.
| return ListTile( | ||
| leading: const Icon(Icons.cloud_outlined), | ||
| title: const Text('Cookidoo'), | ||
| subtitle: Text(subtitle), |
There was a problem hiding this comment.
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.
| 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)}', | ||
| ); |
There was a problem hiding this comment.
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.
| static const _basicAuth = | ||
| 'Basic a3VwZmVyd2Vyay1jbGllbnQtbndvdDpMczUwT04xd295U3FzMWRDZEpnZQ=='; | ||
| static const _clientId = 'kupferwerk-client-nwot'; |
There was a problem hiding this comment.
_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.
| const _keyEmail = 'cookidoo_email'; | ||
| const _keyPassword = 'cookidoo_password'; |
There was a problem hiding this comment.
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.
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>
Summary
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 screenssearch_recipes,get_recipe_detail)search-recipeskill that instructs the on-device LLM to automatically search Cookidoo for inspiration when the user asks for a recipehttppackage dependency for REST API callsTest plan
search_recipesin debug logsget_recipe_detailis called for promising resultsflutter build apk --debug🤖 Generated with Claude Code