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

@@ -49,13 +49,13 @@ class AiModelsSection extends StatelessWidget {
const Divider(height: 1, color: AppColors.border),
const _ModelRow(
name: 'Nomic Embed v1.5 Q4_K_M',
description: 'Text embedding model (~300 MB)',
description: 'Text embedding model (~84 MB)',
icon: Icons.hub_outlined,
),
const Divider(height: 1, color: AppColors.border),
const _ModelRow(
name: 'Qwen 2.5 7B Instruct Q4_K_M',
description: 'Chat / reasoning model (~4.7 GB)',
name: 'Qwen3 4B Instruct 2507 Q4_K_M',
description: 'Chat / reasoning model (~2.4 GB)',
icon: Icons.psychology_outlined,
),
const Divider(height: 1, color: AppColors.border),
@@ -162,7 +162,7 @@ class _StatusAndActions extends StatelessWidget {
const SizedBox(height: UIConstants.spacing16),
if (!modelState.areModelsValidated)
SettingsActionButton(
label: 'Download AI Models (~5 GB)',
label: 'Download AI Models (~2.7 GB)',
icon: Icons.download_rounded,
color: AppColors.accent,
textColor: AppColors.zinc950,
@@ -190,11 +190,13 @@ class _StatusBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
final color = validated ? AppColors.success : AppColors.textMuted;
final bgColor =
validated ? AppColors.successMuted : AppColors.surfaceContainerHigh;
final bgColor = validated
? AppColors.successMuted
: AppColors.surfaceContainerHigh;
final label = validated ? 'Ready' : 'Missing';
final icon =
validated ? Icons.check_circle_outline : Icons.radio_button_unchecked;
final icon = validated
? Icons.check_circle_outline
: Icons.radio_button_unchecked;
return Row(
mainAxisSize: MainAxisSize.min,
@@ -277,8 +279,7 @@ class _DownloadingView extends StatelessWidget {
value: modelState.progress,
minHeight: 6,
backgroundColor: AppColors.zinc800,
valueColor:
const AlwaysStoppedAnimation<Color>(AppColors.accent),
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.accent),
),
),
if (modelState.errorMessage != null) ...[

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,
),
),
],
],
),
],
),
),
],
),
);
}
}

View File

@@ -42,16 +42,15 @@ class _SettingsActionButtonState extends State<SettingsActionButton> {
color: hasBorder
? (_hovered ? AppColors.zinc800 : Colors.transparent)
: (_hovered
? widget.color.withValues(alpha: 0.85)
: widget.color),
? widget.color.withValues(alpha: 0.85)
: widget.color),
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
border: hasBorder ? Border.all(color: widget.borderColor!) : null,
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius:
BorderRadius.circular(UIConstants.smallBorderRadius),
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
onTap: widget.onPressed,
child: Padding(
padding: const EdgeInsets.symmetric(

View File

@@ -78,8 +78,7 @@ class _SettingsIconButtonState extends State<SettingsIconButton> {
child: Icon(
widget.icon,
size: 18,
color:
_hovered ? AppColors.textPrimary : AppColors.textSecondary,
color: _hovered ? AppColors.textPrimary : AppColors.textSecondary,
),
),
),