diff --git a/lib/config/constants.dart b/lib/config/constants.dart index 7549a1a..038f3a3 100644 --- a/lib/config/constants.dart +++ b/lib/config/constants.dart @@ -42,6 +42,14 @@ class AppConstants { static const String youtubeSuggestions = '$apiBase/youtube/suggestions'; static const String settingsYoutubeCookies = '$apiBase/settings/youtube-cookies'; + static const String settingsAiProviders = '$apiBase/settings/ai-providers'; + static String settingsAiKey(String provider) => + '$apiBase/settings/ai-providers/keys/$provider'; + static String settingsAiSelection(String role) => + '$apiBase/settings/ai-providers/selection/$role'; + static const String settingsChatgptLogin = '$apiBase/settings/chatgpt-login'; + static const String settingsChatgptLoginPoll = + '$apiBase/settings/chatgpt-login/poll'; static const String channels = '$apiBase/channels'; static const String channelSubscribe = '$apiBase/channels/subscribe'; static const String channelSubscribeBulk = '$apiBase/channels/subscribe-bulk'; diff --git a/lib/models/ai_providers.dart b/lib/models/ai_providers.dart new file mode 100644 index 0000000..1170505 --- /dev/null +++ b/lib/models/ai_providers.dart @@ -0,0 +1,158 @@ +/// Admin-managed AI provider configuration (Discover pipeline). +/// +/// Parsed from `GET /api/settings/ai-providers`. Keys are never returned by +/// the server — only their status (configured / source / masked last 4). +library; + +/// Status of one provider API key. +class AiKeyStatus { + const AiKeyStatus({ + required this.configured, + required this.source, + required this.last4, + }); + + /// Whether an effective key exists (runtime override or env fallback). + final bool configured; + + /// Where the effective value comes from: `runtime`, `env`, or null. + final String? source; + + /// Last 4 chars of the effective key, or null when hidden/short. + final String? last4; + + factory AiKeyStatus.fromJson(Map json) => AiKeyStatus( + configured: json['configured'] == true, + source: json['source'] as String?, + last4: json['last4'] as String?, + ); +} + +/// The embed or rank provider selection plus what's actually live. +class AiSelection { + const AiSelection({ + required this.provider, + required this.model, + required this.source, + required this.effectiveProvider, + required this.effectiveModel, + required this.options, + }); + + /// The configured provider (`""` means auto-detect). + final String provider; + + /// Optional model override (`""` means the provider default). + final String model; + + /// `runtime` when pinned in-app, else `env`. + final String source; + + /// The provider that will actually be used, or null when none is available. + final String? effectiveProvider; + final String? effectiveModel; + + /// Valid provider options for this role. + final List options; + + factory AiSelection.fromJson(Map json) { + final effective = json['effective'] as Map?; + return AiSelection( + provider: (json['provider'] as String?) ?? '', + model: (json['model'] as String?) ?? '', + source: (json['source'] as String?) ?? 'env', + effectiveProvider: effective?['provider'] as String?, + effectiveModel: effective?['model'] as String?, + options: [for (final o in (json['options'] as List? ?? const [])) '$o'], + ); + } +} + +/// Full AI-config view for the admin panel. +class AiProvidersStatus { + const AiProvidersStatus({ + required this.keys, + required this.embed, + required this.rank, + required this.availability, + }); + + /// Keyed by provider: anthropic / gemini / openai. + final Map keys; + final AiSelection embed; + final AiSelection rank; + + /// Per-provider "usable right now", including `chatgpt` (sign-in based). + final Map availability; + + factory AiProvidersStatus.fromJson(Map json) { + final rawKeys = (json['keys'] as Map?) ?? const {}; + final rawAvail = + (json['availability'] as Map?) ?? const {}; + return AiProvidersStatus( + keys: { + for (final e in rawKeys.entries) + e.key: AiKeyStatus.fromJson(e.value as Map), + }, + embed: AiSelection.fromJson( + (json['embed'] as Map?) ?? const {}, + ), + rank: AiSelection.fromJson( + (json['rank'] as Map?) ?? const {}, + ), + availability: {for (final e in rawAvail.entries) e.key: e.value == true}, + ); + } +} + +/// Status of the ChatGPT (Codex OAuth) sign-in. +class ChatgptLoginStatus { + const ChatgptLoginStatus({ + required this.connected, + required this.needsReauth, + required this.pending, + required this.userCode, + required this.verificationUrl, + }); + + final bool connected; + final bool needsReauth; + final bool pending; + + /// Device code to enter (only while a sign-in is pending). + final String? userCode; + final String? verificationUrl; + + factory ChatgptLoginStatus.fromJson(Map json) => + ChatgptLoginStatus( + connected: json['connected'] == true, + needsReauth: json['needs_reauth'] == true, + pending: json['pending'] == true, + userCode: json['user_code'] as String?, + verificationUrl: json['verification_url'] as String?, + ); +} + +/// One poll of the device sign-in. +class ChatgptPollResult { + const ChatgptPollResult({ + required this.status, + required this.detail, + required this.userCode, + required this.verificationUrl, + }); + + /// idle | pending | connected | expired | error + final String status; + final String? detail; + final String? userCode; + final String? verificationUrl; + + factory ChatgptPollResult.fromJson(Map json) => + ChatgptPollResult( + status: (json['status'] as String?) ?? 'idle', + detail: json['detail'] as String?, + userCode: json['user_code'] as String?, + verificationUrl: json['verification_url'] as String?, + ); +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index e33082e..7b1ebbb 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -13,6 +13,7 @@ import '../providers/settings_provider.dart'; import '../services/api_service.dart'; import '../utils/browser_link.dart'; import '../widgets/adaptive_layout.dart'; +import '../widgets/ai_providers_section.dart'; import '../widgets/app_ui.dart'; import '../widgets/profile_avatar.dart'; @@ -373,6 +374,16 @@ class _SettingsScreenState extends ConsumerState { 'for every profile on this server.', ), const _YoutubeCookiesSection(), + const SizedBox(height: 32), + const _SectionHeader( + icon: Icons.auto_awesome_rounded, + title: 'AI providers', + description: + 'Keys and provider choices for the Discover tab. ' + 'Set here to override the server environment; ' + 'clear to fall back to it.', + ), + const AiProvidersSection(), ], const SizedBox(height: 32), const _SectionHeader( diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 9d8b90d..d705baf 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -8,6 +8,7 @@ import '../models/video_page.dart'; import '../models/feed.dart'; import '../models/recommendation.dart'; import '../models/youtube_import.dart'; +import '../models/ai_providers.dart'; import 'storage_service.dart'; final apiServiceProvider = Provider((ref) { @@ -493,6 +494,73 @@ class ApiService { await _dio.delete('$_baseUrl${AppConstants.settingsYoutubeCookies}'); }); + // --- AI providers (admin) ------------------------------------------------- + + Future getAiProviders() => _guard(() async { + final r = await _dio.get('$_baseUrl${AppConstants.settingsAiProviders}'); + return AiProvidersStatus.fromJson(r.data as Map); + }); + + Future setAiKey(String provider, String key) => + _guard(() async { + final r = await _dio.put( + '$_baseUrl${AppConstants.settingsAiKey(provider)}', + data: {'key': key}, + ); + return AiProvidersStatus.fromJson(r.data as Map); + }); + + Future clearAiKey(String provider) => _guard(() async { + final r = await _dio.delete( + '$_baseUrl${AppConstants.settingsAiKey(provider)}', + ); + return AiProvidersStatus.fromJson(r.data as Map); + }); + + /// Pin the [role] (`embed`/`rank`) provider (+ optional model), or pass an + /// empty provider to auto-detect. + Future setAiSelection( + String role, { + required String provider, + String model = '', + }) => _guard(() async { + final r = await _dio.put( + '$_baseUrl${AppConstants.settingsAiSelection(role)}', + data: {'provider': provider, 'model': model}, + ); + return AiProvidersStatus.fromJson(r.data as Map); + }); + + // --- ChatGPT (Codex OAuth) sign-in (admin) -------------------------------- + + Future getChatgptLogin() => _guard(() async { + final r = await _dio.get('$_baseUrl${AppConstants.settingsChatgptLogin}'); + return ChatgptLoginStatus.fromJson(r.data as Map); + }); + + Future startChatgptLogin() => _guard(() async { + final r = await _dio.post('$_baseUrl${AppConstants.settingsChatgptLogin}'); + // The start response has the same shape as a pending poll. + final data = r.data as Map; + return ChatgptPollResult( + status: 'pending', + detail: null, + userCode: data['user_code'] as String?, + verificationUrl: data['verification_url'] as String?, + ); + }); + + Future pollChatgptLogin() => _guard(() async { + final r = await _dio.post( + '$_baseUrl${AppConstants.settingsChatgptLoginPoll}', + ); + return ChatgptPollResult.fromJson(r.data as Map); + }); + + Future clearChatgptLogin() => _guard(() async { + await _dio.delete('$_baseUrl${AppConstants.settingsChatgptLogin}'); + }); + /// Records an evictable cache claim on [videoId] and (server-side) kicks off /// its HQ download, without it showing in the Downloads tab. Called when the /// user starts instant playback of a not-yet-downloaded video so the player diff --git a/lib/widgets/ai_providers_section.dart b/lib/widgets/ai_providers_section.dart new file mode 100644 index 0000000..2fb0773 --- /dev/null +++ b/lib/widgets/ai_providers_section.dart @@ -0,0 +1,689 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../config/theme.dart'; +import '../models/ai_providers.dart'; +import '../services/api_service.dart'; +import '../utils/browser_link.dart'; + +/// Admin panel for the Discover AI providers: API keys (Anthropic / Gemini / +/// OpenAI), the embed + rank provider selection, and the ChatGPT sign-in that +/// backs the subscription-based rank provider. Values are stored server-side; +/// keys are write-only (never read back). +class AiProvidersSection extends ConsumerStatefulWidget { + const AiProvidersSection({super.key}); + + @override + ConsumerState createState() => _AiProvidersSectionState(); +} + +class _AiProvidersSectionState extends ConsumerState { + static const _keyProviders = ['anthropic', 'gemini', 'openai']; + static const _labels = { + 'anthropic': 'Anthropic', + 'gemini': 'Google Gemini', + 'openai': 'OpenAI', + 'chatgpt': 'ChatGPT sign-in', + }; + + AiProvidersStatus? _status; + bool _loading = true; + + ApiService get _api => ref.read(apiServiceProvider); + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final s = await _api.getAiProviders(); + if (mounted) { + setState(() { + _status = s; + _loading = false; + }); + } + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + void _apply(AiProvidersStatus s) { + if (mounted) setState(() => _status = s); + } + + @override + Widget build(BuildContext context) { + if (_loading) { + return const Card( + child: Padding( + padding: EdgeInsets.all(16), + child: SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + } + final status = _status; + if (status == null) { + return const Card( + child: Padding( + padding: EdgeInsets.all(16), + child: Text("Couldn't load AI provider settings."), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final provider in _keyProviders) ...[ + _ProviderKeyCard( + provider: provider, + label: _labels[provider]!, + status: status.keys[provider], + onSaved: _apply, + ), + const SizedBox(height: 12), + ], + _ChatGptSignInCard( + available: status.availability['chatgpt'] ?? false, + onChanged: _load, + ), + const SizedBox(height: 16), + _SelectionCard( + role: 'embed', + title: 'Embedding provider', + description: + 'Turns your subscriptions into vectors for candidate matching. ' + 'Needs a Gemini or OpenAI key.', + selection: status.embed, + onSaved: _apply, + ), + const SizedBox(height: 12), + _SelectionCard( + role: 'rank', + title: 'Ranking provider', + description: + 'Picks and explains the final recommendations. Can use any key ' + 'above, or the ChatGPT sign-in.', + selection: status.rank, + onSaved: _apply, + ), + ], + ); + } +} + +class _ProviderKeyCard extends ConsumerStatefulWidget { + const _ProviderKeyCard({ + required this.provider, + required this.label, + required this.status, + required this.onSaved, + }); + + final String provider; + final String label; + final AiKeyStatus? status; + final ValueChanged onSaved; + + @override + ConsumerState<_ProviderKeyCard> createState() => _ProviderKeyCardState(); +} + +class _ProviderKeyCardState extends ConsumerState<_ProviderKeyCard> { + final _controller = TextEditingController(); + bool _busy = false; + String? _error; + + ApiService get _api => ref.read(apiServiceProvider); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _save() async { + final key = _controller.text.trim(); + if (key.isEmpty) return; + setState(() { + _busy = true; + _error = null; + }); + try { + final s = await _api.setAiKey(widget.provider, key); + if (!mounted) return; + _controller.clear(); + setState(() => _busy = false); + widget.onSaved(s); + _snack('${widget.label} key saved'); + } on ApiException catch (e) { + if (mounted) { + setState(() { + _error = e.message; + _busy = false; + }); + } + } + } + + Future _clear() async { + setState(() => _busy = true); + try { + final s = await _api.clearAiKey(widget.provider); + if (!mounted) return; + setState(() => _busy = false); + widget.onSaved(s); + } catch (_) { + if (mounted) setState(() => _busy = false); + } + } + + void _snack(String msg) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + } + + @override + Widget build(BuildContext context) { + final status = widget.status; + final configured = status?.configured == true; + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + configured + ? Icons.check_circle_outline_rounded + : Icons.circle_outlined, + size: 18, + color: configured + ? NullFeedTheme.accentColor + : NullFeedTheme.textMuted, + ), + const SizedBox(width: 8), + Text( + widget.label, + style: Theme.of(context).textTheme.titleSmall, + ), + const Spacer(), + if (configured) _keyBadge(status!), + ], + ), + const SizedBox(height: 12), + TextField( + controller: _controller, + obscureText: true, + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + decoration: InputDecoration( + hintText: configured ? 'Paste a new key to replace' : 'API key', + border: const OutlineInputBorder(), + isDense: true, + ), + ), + if (_error != null) ...[ + const SizedBox(height: 8), + Text( + _error!, + style: const TextStyle( + color: NullFeedTheme.errorColor, + fontSize: 12, + ), + ), + ], + const SizedBox(height: 12), + Row( + children: [ + FilledButton( + onPressed: _busy ? null : _save, + child: _busy + ? const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: NullFeedTheme.textSecondary, + ), + ) + : const Text('Save'), + ), + if (configured && status?.source == 'runtime') ...[ + const SizedBox(width: 12), + TextButton( + onPressed: _busy ? null : _clear, + child: const Text( + 'Clear', + style: TextStyle(color: NullFeedTheme.errorColor), + ), + ), + ], + ], + ), + ], + ), + ), + ); + } + + Widget _keyBadge(AiKeyStatus status) { + final env = status.source == 'env'; + final label = status.last4 != null + ? '••${status.last4}' + : (env ? 'from env' : 'set'); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: NullFeedTheme.elevatedSurfaceColor, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + env ? '$label · env' : label, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: NullFeedTheme.textMuted, + ), + ), + ); + } +} + +class _SelectionCard extends ConsumerStatefulWidget { + const _SelectionCard({ + required this.role, + required this.title, + required this.description, + required this.selection, + required this.onSaved, + }); + + final String role; + final String title; + final String description; + final AiSelection selection; + final ValueChanged onSaved; + + @override + ConsumerState<_SelectionCard> createState() => _SelectionCardState(); +} + +class _SelectionCardState extends ConsumerState<_SelectionCard> { + bool _busy = false; + + ApiService get _api => ref.read(apiServiceProvider); + + Future _select(String provider) async { + setState(() => _busy = true); + try { + // Changing the provider clears any stale model override. + final s = await _api.setAiSelection(widget.role, provider: provider); + if (!mounted) return; + setState(() => _busy = false); + widget.onSaved(s); + } on ApiException catch (e) { + if (mounted) { + setState(() => _busy = false); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(e.message))); + } + } + } + + @override + Widget build(BuildContext context) { + final sel = widget.selection; + final effective = sel.effectiveProvider; + // "" is the auto-detect option; render it as a leading entry. + final items = ['', ...sel.options]; + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.title, style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 4), + Text( + widget.description, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: NullFeedTheme.textMuted), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final option in items) + ChoiceChip( + label: Text(option.isEmpty ? 'Auto' : option), + selected: sel.provider == option, + onSelected: _busy ? null : (_) => _select(option), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Icon( + effective != null + ? Icons.bolt_rounded + : Icons.error_outline_rounded, + size: 16, + color: effective != null + ? NullFeedTheme.accentColor + : NullFeedTheme.errorColor, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + effective != null + ? 'Using $effective (${sel.effectiveModel})' + '${sel.provider.isEmpty ? ' · auto' : ''}' + : 'None available — add a key or sign in', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: NullFeedTheme.textMuted, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _ChatGptSignInCard extends ConsumerStatefulWidget { + const _ChatGptSignInCard({required this.available, required this.onChanged}); + + final bool available; + + /// Called after a state change (connected / signed out) so the parent can + /// refresh availability + effective-provider labels. + final Future Function() onChanged; + + @override + ConsumerState<_ChatGptSignInCard> createState() => _ChatGptSignInCardState(); +} + +class _ChatGptSignInCardState extends ConsumerState<_ChatGptSignInCard> { + ChatgptLoginStatus? _status; + Timer? _poller; + String? _userCode; + String? _verificationUrl; + bool _busy = false; + String? _error; + + ApiService get _api => ref.read(apiServiceProvider); + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _poller?.cancel(); + super.dispose(); + } + + Future _load() async { + try { + final s = await _api.getChatgptLogin(); + if (!mounted) return; + setState(() { + _status = s; + if (s.pending) { + _userCode = s.userCode; + _verificationUrl = s.verificationUrl; + _startPolling(); + } + }); + } catch (_) { + /* leave as unknown */ + } + } + + Future _connect() async { + setState(() { + _busy = true; + _error = null; + }); + try { + final r = await _api.startChatgptLogin(); + if (!mounted) return; + setState(() { + _busy = false; + _userCode = r.userCode; + _verificationUrl = r.verificationUrl; + }); + if (r.verificationUrl != null) openInNewTab(r.verificationUrl!); + _startPolling(); + } on ApiException catch (e) { + if (mounted) { + setState(() { + _busy = false; + _error = e.message; + }); + } + } + } + + void _startPolling() { + _poller?.cancel(); + _poller = Timer.periodic(const Duration(seconds: 3), (_) => _poll()); + } + + Future _poll() async { + ChatgptPollResult r; + try { + r = await _api.pollChatgptLogin(); + } catch (_) { + return; // transient; keep polling + } + if (!mounted) return; + switch (r.status) { + case 'connected': + _poller?.cancel(); + setState(() { + _userCode = null; + _verificationUrl = null; + }); + await _load(); + await widget.onChanged(); + break; + case 'pending': + setState(() { + _userCode = r.userCode ?? _userCode; + _verificationUrl = r.verificationUrl ?? _verificationUrl; + }); + break; + case 'expired': + case 'error': + case 'idle': + _poller?.cancel(); + setState(() { + _userCode = null; + _verificationUrl = null; + _error = r.detail; + }); + break; + } + } + + Future _disconnect() async { + setState(() => _busy = true); + try { + await _api.clearChatgptLogin(); + _poller?.cancel(); + if (!mounted) return; + setState(() { + _busy = false; + _userCode = null; + _verificationUrl = null; + }); + await _load(); + await widget.onChanged(); + } catch (_) { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final status = _status; + final connected = status?.connected == true; + final signingIn = _userCode != null; + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + connected + ? Icons.check_circle_outline_rounded + : Icons.circle_outlined, + size: 18, + color: connected + ? NullFeedTheme.accentColor + : NullFeedTheme.textMuted, + ), + const SizedBox(width: 8), + Text( + 'ChatGPT subscription', + style: Theme.of(context).textTheme.titleSmall, + ), + const Spacer(), + if (status?.needsReauth == true) + const Text( + 'reconnect', + style: TextStyle( + color: NullFeedTheme.errorColor, + fontSize: 12, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + 'Rank Discover on a ChatGPT Plus/Pro plan instead of an OpenAI ' + 'key. Enable device authorization in ChatGPT → Settings → ' + 'Security first. Shares your plan\'s Codex usage limits.', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: NullFeedTheme.textMuted), + ), + if (signingIn) ...[const SizedBox(height: 12), _codePanel()], + if (_error != null) ...[ + const SizedBox(height: 8), + Text( + _error!, + style: const TextStyle( + color: NullFeedTheme.errorColor, + fontSize: 12, + ), + ), + ], + const SizedBox(height: 12), + Row( + children: [ + if (!connected && !signingIn) + FilledButton( + onPressed: _busy ? null : _connect, + child: _busy + ? const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: NullFeedTheme.textSecondary, + ), + ) + : const Text('Connect'), + ), + if (signingIn) + const Text( + 'Waiting for approval…', + style: TextStyle( + color: NullFeedTheme.textMuted, + fontSize: 13, + ), + ), + if (connected) + TextButton( + onPressed: _busy ? null : _disconnect, + child: const Text( + 'Sign out', + style: TextStyle(color: NullFeedTheme.errorColor), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _codePanel() { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: NullFeedTheme.elevatedSurfaceColor, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Enter this code at the ChatGPT page:'), + const SizedBox(height: 6), + SelectableText( + _userCode ?? '', + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 22, + letterSpacing: 2, + color: NullFeedTheme.textPrimary, + ), + ), + if (_verificationUrl != null) ...[ + const SizedBox(height: 6), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: () => openInNewTab(_verificationUrl!), + icon: const Icon(Icons.open_in_new_rounded, size: 16), + label: const Text('Open the sign-in page'), + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ), + ], + ], + ), + ); + } +} diff --git a/test/widgets/ai_providers_section_test.dart b/test/widgets/ai_providers_section_test.dart new file mode 100644 index 0000000..20669c2 --- /dev/null +++ b/test/widgets/ai_providers_section_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:nullfeed/config/theme.dart'; +import 'package:nullfeed/models/ai_providers.dart'; +import 'package:nullfeed/services/api_service.dart'; +import 'package:nullfeed/widgets/ai_providers_section.dart'; + +import '../helpers/test_helpers.dart'; + +AiProvidersStatus _status({ + Map? geminiKey, + String rankProvider = '', + Map? rankEffective, + bool chatgpt = false, +}) => AiProvidersStatus.fromJson({ + 'keys': { + 'anthropic': {'configured': false, 'source': null, 'last4': null}, + 'gemini': geminiKey ?? {'configured': false, 'source': null, 'last4': null}, + 'openai': {'configured': false, 'source': null, 'last4': null}, + }, + 'embed': { + 'provider': '', + 'model': '', + 'source': 'env', + 'effective': null, + 'options': ['gemini', 'openai'], + }, + 'rank': { + 'provider': rankProvider, + 'model': '', + 'source': rankProvider.isEmpty ? 'env' : 'runtime', + 'effective': rankEffective, + 'options': ['anthropic', 'gemini', 'openai', 'chatgpt'], + }, + 'availability': { + 'anthropic': false, + 'gemini': geminiKey?['configured'] == true, + 'openai': false, + 'chatgpt': chatgpt, + }, +}); + +void main() { + late MockApiService api; + + setUp(() { + api = MockApiService(); + when(() => api.getChatgptLogin()).thenAnswer( + (_) async => ChatgptLoginStatus.fromJson({'connected': false}), + ); + }); + + Future pump(WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [apiServiceProvider.overrideWithValue(api)], + child: MaterialApp( + theme: NullFeedTheme.darkTheme, + home: const Scaffold( + body: SingleChildScrollView(child: AiProvidersSection()), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('renders a key card per provider and the selection cards', ( + tester, + ) async { + when(() => api.getAiProviders()).thenAnswer((_) async => _status()); + await pump(tester); + + expect(find.text('Anthropic'), findsOneWidget); + expect(find.text('Google Gemini'), findsOneWidget); + expect(find.text('OpenAI'), findsOneWidget); + expect(find.text('Embedding provider'), findsOneWidget); + expect(find.text('Ranking provider'), findsOneWidget); + // No provider available yet. + expect(find.textContaining('None available'), findsWidgets); + }); + + testWidgets('shows the masked key badge and effective provider', ( + tester, + ) async { + when(() => api.getAiProviders()).thenAnswer( + (_) async => _status( + geminiKey: {'configured': true, 'source': 'runtime', 'last4': 'wxyz'}, + rankProvider: 'gemini', + rankEffective: {'provider': 'gemini', 'model': 'gemini-3.5-flash'}, + ), + ); + await pump(tester); + + expect(find.text('••wxyz'), findsOneWidget); + expect(find.textContaining('Using gemini'), findsWidgets); + }); + + testWidgets('saving a key calls setAiKey and clears the field', ( + tester, + ) async { + when(() => api.getAiProviders()).thenAnswer((_) async => _status()); + when( + () => api.setAiKey('openai', 'sk-test'), + ).thenAnswer((_) async => _status()); + await pump(tester); + + final field = find.widgetWithText(TextField, 'API key').last; + await tester.enterText(field, 'sk-test'); + await tester.tap(find.widgetWithText(FilledButton, 'Save').last); + await tester.pumpAndSettle(); + + verify(() => api.setAiKey('openai', 'sk-test')).called(1); + }); +}