Przebudowanie aplikacji, usprawnione AI, dodanie combo buildera
Some checks failed
Build Linux App / build (push) Failing after 1m18s

This commit is contained in:
Kazimierz Ciołek
2026-07-06 23:44:17 +02:00
parent 9dcc4b87de
commit 6dd7213eb0
48 changed files with 3229 additions and 669 deletions

View File

@@ -0,0 +1,242 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/data/services/ai_settings_service.dart';
import 'package:trainhub_flutter/injection.dart';
/// Lets the user pick which AI backend answers chat: the bundled local model
/// or a cloud API (Anthropic / OpenAI / Gemini) with their own key.
class AiProviderSection extends StatefulWidget {
const AiProviderSection({super.key});
@override
State<AiProviderSection> createState() => _AiProviderSectionState();
}
class _AiProviderSectionState extends State<AiProviderSection> {
late final AiSettingsService _service = getIt<AiSettingsService>();
late AiProvider _provider;
late final TextEditingController _apiKeyController;
late final TextEditingController _modelController;
late final TextEditingController _baseUrlController;
bool _obscureKey = true;
bool _saved = false;
@override
void initState() {
super.initState();
_provider = _service.settings.provider;
_apiKeyController = TextEditingController(
text: _service.settings.apiKeys[_provider.name] ?? '',
);
_modelController = TextEditingController(
text: _service.settings.modelFor(_provider),
);
_baseUrlController = TextEditingController(
text: _service.settings.baseUrlFor(_provider),
);
}
@override
void dispose() {
_apiKeyController.dispose();
_modelController.dispose();
_baseUrlController.dispose();
super.dispose();
}
void _onProviderChanged(AiProvider? provider) {
if (provider == null) return;
setState(() {
_provider = provider;
_apiKeyController.text = _service.settings.apiKeys[provider.name] ?? '';
_modelController.text = _service.settings.modelFor(provider);
_baseUrlController.text = _service.settings.baseUrlFor(provider);
_saved = false;
});
}
Future<void> _save() async {
final settings = _service.settings;
await _service.save(
settings.copyWith(
provider: _provider,
apiKeys: {
...settings.apiKeys,
_provider.name: _apiKeyController.text.trim(),
},
models: {
...settings.models,
_provider.name: _modelController.text.trim(),
},
baseUrls: {
...settings.baseUrls,
_provider.name: _baseUrlController.text.trim(),
},
),
);
if (mounted) setState(() => _saved = true);
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColors.surfaceContainer,
borderRadius: UIConstants.cardBorderRadius,
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(UIConstants.spacing16),
child: Row(
children: [
const Icon(
Icons.cloud_outlined,
size: 18,
color: AppColors.accent,
),
const SizedBox(width: 10),
Text(
'AI PROVIDER',
style: GoogleFonts.chakraPetch(
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 1,
color: AppColors.textPrimary,
),
),
],
),
),
const Divider(height: 1, color: AppColors.border),
Padding(
padding: const EdgeInsets.all(UIConstants.spacing16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Chat backend. The knowledge base always uses the local '
'embedding model — your notes never leave this machine. '
'With a cloud provider, chat messages plus your exercise '
'and plan names are sent to that provider.',
style: GoogleFonts.inter(
fontSize: 12,
color: AppColors.textSecondary,
height: 1.5,
),
),
const SizedBox(height: UIConstants.spacing16),
DropdownButtonFormField<AiProvider>(
initialValue: _provider,
onChanged: _onProviderChanged,
dropdownColor: AppColors.surfaceContainerHigh,
style: GoogleFonts.inter(
fontSize: 13,
color: AppColors.textPrimary,
),
decoration: const InputDecoration(labelText: 'Provider'),
items: [
for (final p in AiProvider.values)
DropdownMenuItem(value: p, child: Text(p.label)),
],
),
if (_provider.isSelfHosted) ...[
const SizedBox(height: UIConstants.spacing12),
TextField(
controller: _baseUrlController,
onChanged: (_) => setState(() => _saved = false),
style: GoogleFonts.jetBrainsMono(
fontSize: 12,
color: AppColors.textPrimary,
),
decoration: InputDecoration(
labelText: 'Server URL',
helperText: 'Default: ${_provider.defaultBaseUrl}',
helperStyle: GoogleFonts.inter(
fontSize: 11,
color: AppColors.textMuted,
),
),
),
],
if (_provider.needsApiKey) ...[
const SizedBox(height: UIConstants.spacing12),
TextField(
controller: _apiKeyController,
obscureText: _obscureKey,
onChanged: (_) => setState(() => _saved = false),
style: GoogleFonts.jetBrainsMono(
fontSize: 12,
color: AppColors.textPrimary,
),
decoration: InputDecoration(
labelText: 'API key',
suffixIcon: IconButton(
icon: Icon(
_obscureKey
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
size: 16,
),
onPressed: () =>
setState(() => _obscureKey = !_obscureKey),
),
),
),
],
if (_provider != AiProvider.local) ...[
const SizedBox(height: UIConstants.spacing12),
TextField(
controller: _modelController,
onChanged: (_) => setState(() => _saved = false),
style: GoogleFonts.jetBrainsMono(
fontSize: 12,
color: AppColors.textPrimary,
),
decoration: InputDecoration(
labelText: 'Model',
helperText: _provider == AiProvider.ollama
? 'Name of a model pulled in Ollama, '
'e.g. qwen3:4b, llama3.1'
: 'Default: ${_provider.defaultModel}',
helperStyle: GoogleFonts.inter(
fontSize: 11,
color: AppColors.textMuted,
),
),
),
],
const SizedBox(height: UIConstants.spacing16),
Row(
children: [
FilledButton(onPressed: _save, child: const Text('SAVE')),
if (_saved) ...[
const SizedBox(width: UIConstants.spacing12),
const Icon(
Icons.check_circle_outline,
size: 16,
color: AppColors.success,
),
const SizedBox(width: 6),
Text(
'Saved',
style: GoogleFonts.inter(
fontSize: 12,
color: AppColors.success,
),
),
],
],
),
],
),
),
],
),
);
}
}