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

@@ -1,10 +1,13 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:trainhub_flutter/core/constants/ai_constants.dart';
import 'package:trainhub_flutter/domain/repositories/chat_repository.dart';
import 'package:trainhub_flutter/domain/repositories/exercise_repository.dart';
import 'package:trainhub_flutter/domain/repositories/note_repository.dart';
import 'package:trainhub_flutter/domain/repositories/training_plan_repository.dart';
import 'package:trainhub_flutter/data/services/ai_process_manager.dart';
import 'package:trainhub_flutter/data/services/ai_settings_service.dart';
import 'package:trainhub_flutter/data/services/llm_client.dart';
import 'package:trainhub_flutter/injection.dart';
import 'package:trainhub_flutter/presentation/chat/chat_state.dart';
import 'package:uuid/uuid.dart';
@@ -18,22 +21,27 @@ AiProcessManager aiProcessManager(AiProcessManagerRef ref) {
return manager;
}
@riverpod
AiSettingsService aiSettingsService(AiSettingsServiceRef ref) {
final service = getIt<AiSettingsService>();
service.addListener(() => ref.notifyListeners());
return service;
}
@riverpod
class ChatController extends _$ChatController {
late ChatRepository _repo;
late NoteRepository _noteRepo;
final _dio = Dio(
BaseOptions(
connectTimeout: AiConstants.serverConnectTimeout,
receiveTimeout: AiConstants.serverReceiveTimeout,
),
);
late LlmClient _llm;
CancelToken? _cancelToken;
@override
Future<ChatState> build() async {
_repo = getIt<ChatRepository>();
_noteRepo = getIt<NoteRepository>();
_llm = getIt<LlmClient>();
// Abort any in-flight generation when the user leaves the chat page.
ref.onDispose(() => _cancelToken?.cancel());
final aiManager = ref.read(aiProcessManagerProvider);
if (aiManager.status == AiServerStatus.offline) {
aiManager.startServers();
@@ -67,8 +75,9 @@ class ChatController extends _$ChatController {
state = AsyncValue.data(
current.copyWith(
sessions: sessions,
activeSession:
current.activeSession?.id == id ? null : current.activeSession,
activeSession: current.activeSession?.id == id
? null
: current.activeSession,
messages: current.activeSession?.id == id ? [] : current.messages,
),
);
@@ -80,12 +89,44 @@ class ChatController extends _$ChatController {
final sessionId = await _resolveSession(current, content);
await _persistUserMessage(sessionId, content);
final contextChunks = await _searchKnowledgeBase(content);
final systemPrompt = _buildSystemPrompt(contextChunks);
final trainingContext = await _buildTrainingContext();
final systemPrompt = _buildSystemPrompt(contextChunks, trainingContext);
final history = _buildHistory();
final fullAiResponse = await _streamResponse(systemPrompt, history);
await _persistAssistantResponse(sessionId, content, fullAiResponse);
}
/// Summarizes the user's exercise library and training plans so the model
/// can reference and plan around real data. Only attached when a cloud
/// provider is active — the local 4B model's context is too small for it.
Future<String> _buildTrainingContext() async {
if (getIt<LlmClient>().activeProvider == AiProvider.local) return '';
try {
final exercises = await getIt<ExerciseRepository>().getAll();
final plans = await getIt<TrainingPlanRepository>().getAll();
final buffer = StringBuffer();
if (exercises.isNotEmpty) {
buffer.writeln("### The trainer's exercise library:");
for (final e in exercises.take(150)) {
buffer.write('- ${e.name}');
final tags = e.tags;
if (tags != null && tags.isNotEmpty) buffer.write(' [$tags]');
buffer.writeln();
}
}
if (plans.isNotEmpty) {
buffer.writeln("\n### The trainer's training plans:");
for (final plan in plans.take(30)) {
buffer.writeln('- ${plan.name}');
}
}
return buffer.toString();
} catch (_) {
return '';
}
}
Future<String> _resolveSession(ChatState current, String content) async {
if (current.activeSession != null) return current.activeSession!.id;
final session = await _repo.createSession();
@@ -144,61 +185,51 @@ class ChatController extends _$ChatController {
return contextChunks;
}
/// Most recent messages only — an unbounded history would eventually
/// overflow the model context and slow every request down.
List<Map<String, String>> _buildHistory() {
final messages = state.valueOrNull?.messages ?? [];
return messages
.map((m) => <String, String>{
'role': m.isUser ? 'user' : 'assistant',
'content': m.content,
})
final recent = messages.length > AiConstants.chatHistoryLimit
? messages.sublist(messages.length - AiConstants.chatHistoryLimit)
: messages;
return recent
.map(
(m) => <String, String>{
'role': m.isUser ? 'user' : 'assistant',
'content': m.content,
},
)
.toList();
}
/// Stops an in-flight generation. The partial response streamed so far is
/// kept and persisted like a normal reply.
void stopGeneration() => _cancelToken?.cancel();
Future<String> _streamResponse(
String systemPrompt,
List<Map<String, String>> history,
) async {
final generateStep = _createStep('Generating response...');
String fullAiResponse = '';
_cancelToken = CancelToken();
try {
final response = await _dio.post<ResponseBody>(
AiConstants.chatApiUrl,
options: Options(responseType: ResponseType.stream),
data: {
'messages': [
{'role': 'system', 'content': systemPrompt},
...history,
],
'temperature': AiConstants.chatTemperature,
'stream': true,
},
);
final stream = _llm.streamChat([
{'role': 'system', 'content': systemPrompt},
...history,
], cancelToken: _cancelToken);
_updateStep(
generateStep.id,
status: ThinkingStepStatus.running,
title: 'Writing...',
);
final stream = response.data!.stream;
await for (final chunk in stream) {
final textChunk = utf8.decode(chunk);
for (final line in textChunk.split('\n')) {
if (!line.startsWith('data: ')) continue;
final dataStr = line.substring(6).trim();
if (dataStr == '[DONE]') break;
if (dataStr.isEmpty) continue;
try {
final data = jsonDecode(dataStr);
final delta = data['choices']?[0]?['delta']?['content'] ?? '';
if (delta.isNotEmpty) {
fullAiResponse += delta;
final updatedState = state.valueOrNull;
if (updatedState != null) {
state = AsyncValue.data(
updatedState.copyWith(streamingContent: fullAiResponse),
);
}
}
} catch (_) {}
await for (final delta in stream) {
fullAiResponse += delta;
final updatedState = state.valueOrNull;
if (updatedState != null) {
state = AsyncValue.data(
updatedState.copyWith(streamingContent: fullAiResponse),
);
}
}
_updateStep(
@@ -207,21 +238,29 @@ class ChatController extends _$ChatController {
title: 'Response generated',
);
} on DioException catch (e) {
fullAiResponse += '\n\n[AI model communication error]';
_updateStep(
generateStep.id,
status: ThinkingStepStatus.error,
title: 'Generation failed',
details: '${e.message}',
);
if (CancelToken.isCancel(e)) {
_updateStep(
generateStep.id,
status: ThinkingStepStatus.completed,
title: 'Stopped by user',
);
} else {
_updateStep(
generateStep.id,
status: ThinkingStepStatus.error,
title: 'Generation failed',
details: e.message ?? e.toString(),
);
}
} catch (e) {
fullAiResponse += '\n\n[Unexpected error]';
_updateStep(
generateStep.id,
status: ThinkingStepStatus.error,
title: 'Generation failed',
details: e.toString(),
);
} finally {
_cancelToken = null;
}
return fullAiResponse;
}
@@ -231,6 +270,17 @@ class ChatController extends _$ChatController {
String userContent,
String aiResponse,
) async {
// A failed generation yields an empty response — leave the error visible
// in the thinking steps instead of saving an empty assistant message.
if (aiResponse.trim().isEmpty) {
final current = state.valueOrNull;
if (current != null) {
state = AsyncValue.data(
current.copyWith(isTyping: false, streamingContent: null),
);
}
return;
}
await _repo.addMessage(
sessionId: sessionId,
role: 'assistant',
@@ -289,18 +339,32 @@ class ChatController extends _$ChatController {
state = AsyncValue.data(current.copyWith(thinkingSteps: updatedSteps));
}
static String _buildSystemPrompt(List<String> chunks) {
if (chunks.isEmpty) return AiConstants.baseSystemPrompt;
final contextBlock = chunks
.asMap()
.entries
.map((e) => '[${e.key + 1}] ${e.value}')
.join('\n\n');
return '${AiConstants.baseSystemPrompt}\n\n'
'### Relevant notes from the trainer\'s knowledge base:\n'
static String _buildSystemPrompt(
List<String> chunks,
String trainingContext,
) {
final buffer = StringBuffer(AiConstants.baseSystemPrompt);
if (trainingContext.isNotEmpty) {
buffer.write('\n\n$trainingContext');
buffer.write(
'\nWhen designing or discussing training plans, prefer exercises '
'from the library above and reference existing plans by name.',
);
}
if (chunks.isNotEmpty) {
final contextBlock = chunks
.asMap()
.entries
.map((e) => '[${e.key + 1}] ${e.value}')
.join('\n\n');
buffer.write(
'\n\n### Relevant notes from the trainer\'s knowledge base:\n'
'$contextBlock\n\n'
'Use the above context to inform your response when relevant. '
'If the context is not directly applicable, rely on your general '
'fitness knowledge.';
'fitness knowledge.',
);
}
return buffer.toString();
}
}

View File

@@ -23,7 +23,25 @@ final aiProcessManagerProvider = AutoDisposeProvider<AiProcessManager>.internal(
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef AiProcessManagerRef = AutoDisposeProviderRef<AiProcessManager>;
String _$chatControllerHash() => r'266d8a5ac91cbe6c112f85f15adf5a8046e85682';
String _$aiSettingsServiceHash() => r'3d16401bd92a2502e16cc9bc55558e0813682f4b';
/// See also [aiSettingsService].
@ProviderFor(aiSettingsService)
final aiSettingsServiceProvider =
AutoDisposeProvider<AiSettingsService>.internal(
aiSettingsService,
name: r'aiSettingsServiceProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$aiSettingsServiceHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef AiSettingsServiceRef = AutoDisposeProviderRef<AiSettingsService>;
String _$chatControllerHash() => r'227ef80f7bcc8787d85a726f151d878a3ba954d6';
/// See also [ChatController].
@ProviderFor(ChatController)

View File

@@ -5,6 +5,7 @@ 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_process_manager.dart';
import 'package:trainhub_flutter/data/services/ai_settings_service.dart';
import 'package:trainhub_flutter/domain/entities/chat_session.dart';
import 'package:trainhub_flutter/presentation/chat/chat_controller.dart';
import 'package:trainhub_flutter/presentation/chat/chat_state.dart';
@@ -63,9 +64,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
final now = DateTime.now();
final hour = dt.hour.toString().padLeft(2, '0');
final minute = dt.minute.toString().padLeft(2, '0');
if (dt.year == now.year &&
dt.month == now.month &&
dt.day == now.day) {
if (dt.year == now.year && dt.month == now.month && dt.day == now.day) {
return '$hour:$minute';
}
return '${dt.day}/${dt.month} $hour:$minute';
@@ -76,14 +75,19 @@ class _ChatPageState extends ConsumerState<ChatPage> {
@override
Widget build(BuildContext context) {
final modelsValidated =
ref.watch(aiModelSettingsControllerProvider).areModelsValidated;
final modelsValidated = ref
.watch(aiModelSettingsControllerProvider)
.areModelsValidated;
// A configured external provider (cloud, Ollama, LM Studio) makes chat
// usable without the built-in local models.
final settings = ref.watch(aiSettingsServiceProvider).settings;
final cloudConfigured =
settings.provider != AiProvider.local && settings.isProviderConfigured;
final state = ref.watch(chatControllerProvider);
final controller = ref.read(chatControllerProvider.notifier);
ref.listen(chatControllerProvider, (prev, next) {
if (next.hasValue &&
(prev?.value?.messages.length ?? 0) <
next.value!.messages.length) {
(prev?.value?.messages.length ?? 0) < next.value!.messages.length) {
_scrollToBottom();
}
if (next.hasValue &&
@@ -92,7 +96,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
_scrollToBottom();
}
});
if (!modelsValidated) {
if (!modelsValidated && !cloudConfigured) {
return const Scaffold(
backgroundColor: AppColors.surface,
body: MissingModelsState(),
@@ -211,14 +215,11 @@ class _ChatPageState extends ConsumerState<ChatPage> {
color: isActive
? AppColors.zinc700.withValues(alpha: 0.7)
: isHovered
? AppColors.zinc800.withValues(alpha: 0.6)
: Colors.transparent,
borderRadius:
BorderRadius.circular(UIConstants.smallBorderRadius),
? AppColors.zinc800.withValues(alpha: 0.6)
: Colors.transparent,
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
border: isActive
? Border.all(
color: AppColors.accent.withValues(alpha: 0.3),
)
? Border.all(color: AppColors.accent.withValues(alpha: 0.3))
: null,
),
child: Row(
@@ -239,8 +240,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
? AppColors.textPrimary
: AppColors.textSecondary,
fontSize: 13,
fontWeight:
isActive ? FontWeight.w500 : FontWeight.normal,
fontWeight: isActive ? FontWeight.w500 : FontWeight.normal,
),
),
),
@@ -260,8 +260,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
Icons.delete_outline_rounded,
color: AppColors.textMuted,
),
onPressed: () =>
controller.deleteSession(session.id),
onPressed: () => controller.deleteSession(session.id),
tooltip: 'Delete',
),
),
@@ -290,8 +289,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
horizontal: UIConstants.spacing24,
vertical: UIConstants.spacing16,
),
itemCount:
data.messages.length + (data.isTyping ? 1 : 0),
itemCount: data.messages.length + (data.isTyping ? 1 : 0),
itemBuilder: (context, index) {
if (index == data.messages.length) {
if (data.thinkingSteps.isNotEmpty ||
@@ -391,21 +389,46 @@ class _ChatPageState extends ConsumerState<ChatPage> {
ChatController controller,
) {
final aiManager = ref.watch(aiProcessManagerProvider);
final aiSettings = ref.watch(aiSettingsServiceProvider).settings;
final isTyping = asyncState.valueOrNull?.isTyping ?? false;
final isStarting = aiManager.status == AiServerStatus.starting;
final isError = aiManager.status == AiServerStatus.error;
final isReady = aiManager.status == AiServerStatus.ready;
// External providers (cloud, Ollama, LM Studio) don't need the built-in
// llama server for chat. The local embedding server is still used for
// the knowledge base when available, but its absence shouldn't block.
final isCloud = aiSettings.provider != AiProvider.local;
final cloudReady = isCloud && aiSettings.isProviderConfigured;
final isReady = cloudReady || aiManager.status == AiServerStatus.ready;
String? statusMessage;
Color statusColor = AppColors.textMuted;
if (isStarting) {
if (isCloud) {
if (!cloudReady) {
statusMessage =
'No API key for ${aiSettings.provider.label} — add one in '
'Settings, or switch back to the local model.';
statusColor = AppColors.warning;
} else {
statusMessage =
'${aiSettings.provider.label} · '
'${aiSettings.modelFor(aiSettings.provider)}';
}
} else if (isStarting) {
statusMessage =
aiManager.statusDetail ??
'Starting AI inference server (this may take a moment)...';
statusColor = AppColors.info;
} else if (isError) {
statusMessage =
'AI Server Error: ${aiManager.errorMessage ?? "Unknown error"}';
// The full error contains multi-line server logs — show only the
// first line here; details land in the debug console.
final firstLine = (aiManager.errorMessage ?? 'Unknown error')
.split('\n')
.first
.trim();
statusMessage = 'AI Server Error: $firstLine';
statusColor = AppColors.destructive;
} else if (!isReady) {
} else if (aiManager.status != AiServerStatus.ready) {
statusMessage = 'AI Server offline. Reconnecting...';
}
return Container(
@@ -424,7 +447,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
padding: const EdgeInsets.only(bottom: UIConstants.spacing8),
child: Row(
children: [
if (isStarting)
if (!isCloud && isStarting)
Container(
margin: const EdgeInsets.only(right: 8),
width: 12,
@@ -434,7 +457,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
color: AppColors.info,
),
)
else if (isError)
else if (!isCloud && isError)
const Padding(
padding: EdgeInsets.only(right: 8),
child: Icon(
@@ -453,7 +476,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
),
),
),
if (isError)
if (!isCloud && isError)
TextButton(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
@@ -479,10 +502,10 @@ class _ChatPageState extends ConsumerState<ChatPage> {
child: Container(
decoration: BoxDecoration(
color: AppColors.surfaceContainer,
borderRadius:
BorderRadius.circular(UIConstants.borderRadius),
border:
Border.all(color: AppColors.border, width: 1),
borderRadius: BorderRadius.circular(
UIConstants.borderRadius,
),
border: Border.all(color: AppColors.border, width: 1),
),
child: TextField(
controller: _inputController,
@@ -525,22 +548,23 @@ class _ChatPageState extends ConsumerState<ChatPage> {
width: 40,
height: 40,
child: Material(
color: (isTyping || !isReady)
? AppColors.zinc700
: AppColors.accent,
borderRadius:
BorderRadius.circular(UIConstants.borderRadius),
// While generating, the button becomes a stop control.
color: !isReady ? AppColors.zinc700 : AppColors.accent,
borderRadius: BorderRadius.circular(UIConstants.borderRadius),
child: InkWell(
borderRadius:
BorderRadius.circular(UIConstants.borderRadius),
onTap: (isTyping || !isReady)
borderRadius: BorderRadius.circular(
UIConstants.borderRadius,
),
onTap: !isReady
? null
: isTyping
? controller.stopGeneration
: () => _sendMessage(controller),
child: Icon(
Icons.arrow_upward_rounded,
color: (isTyping || !isReady)
? AppColors.textMuted
: AppColors.zinc950,
isTyping
? Icons.stop_rounded
: Icons.arrow_upward_rounded,
color: !isReady ? AppColors.textMuted : AppColors.zinc950,
size: 20,
),
),

View File

@@ -29,11 +29,7 @@ class AppStatCard extends StatelessWidget {
borderRadius: UIConstants.cardBorderRadius,
child: Row(
children: [
Container(
width: 4,
height: 72,
color: accentColor,
),
Container(width: 4, height: 72, color: accentColor),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
@@ -48,17 +44,18 @@ class AppStatCard extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w500,
title.toUpperCase(),
style: GoogleFonts.jetBrainsMono(
fontSize: 10,
fontWeight: FontWeight.w600,
letterSpacing: 1.2,
color: AppColors.textMuted,
),
),
const SizedBox(height: UIConstants.spacing4),
Text(
value,
style: GoogleFonts.inter(
style: GoogleFonts.jetBrainsMono(
fontSize: 24,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
@@ -67,12 +64,7 @@ class AppStatCard extends StatelessWidget {
],
),
),
if (icon != null)
Icon(
icon,
size: 20,
color: accentColor,
),
if (icon != null) Icon(icon, size: 20, color: accentColor),
],
),
),

View File

@@ -0,0 +1,405 @@
import 'dart:ui' show PointMode;
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
/// A node placed on a [NodeGraphCanvas].
class GraphNodeData {
const GraphNodeData({
required this.id,
required this.title,
this.subtitle,
this.accent = AppColors.accent,
required this.position,
});
final String id;
final String title;
final String? subtitle;
final Color accent;
final Offset position;
}
/// A directed edge between two nodes.
class GraphEdgeData {
const GraphEdgeData(this.fromId, this.toId);
final String fromId;
final String toId;
}
/// A pannable / zoomable node-graph editor in the dojang style.
///
/// Interactions:
/// - drag empty space to pan, pinch / scroll to zoom
/// - drag a node to move it ([onNodeMoved] per frame, [onNodeDragEnd] once)
/// - tap the link button on a node, then tap another node to connect them
/// ([onConnect]); connecting an already-linked pair is the caller's chance
/// to toggle the edge off
/// - long-press a node for the caller's context action ([onNodeLongPress])
class NodeGraphCanvas extends StatefulWidget {
const NodeGraphCanvas({
super.key,
required this.nodes,
required this.edges,
this.onNodeMoved,
this.onNodeDragEnd,
this.onConnect,
this.onNodeTap,
this.onNodeLongPress,
this.canvasSize = const Size(3000, 2000),
});
final List<GraphNodeData> nodes;
final List<GraphEdgeData> edges;
final void Function(String id, Offset position)? onNodeMoved;
final void Function(String id)? onNodeDragEnd;
final void Function(String fromId, String toId)? onConnect;
final void Function(String id)? onNodeTap;
final void Function(String id)? onNodeLongPress;
final Size canvasSize;
static const double nodeWidth = 172;
static const double nodeHeight = 56;
@override
State<NodeGraphCanvas> createState() => _NodeGraphCanvasState();
}
class _NodeGraphCanvasState extends State<NodeGraphCanvas> {
final _transform = TransformationController();
String? _connectingFrom;
/// Node currently being dragged. While set, the InteractiveViewer's own
/// pan/zoom is disabled so the two gestures never fight for the pointer —
/// this is what makes node dragging feel 1:1 with the mouse.
String? _draggingId;
@override
void dispose() {
_transform.dispose();
super.dispose();
}
double get _scale => _transform.value.getMaxScaleOnAxis();
void _handleNodeBodyTap(String id) {
final from = _connectingFrom;
if (from != null) {
setState(() => _connectingFrom = null);
if (from != id) widget.onConnect?.call(from, id);
return;
}
widget.onNodeTap?.call(id);
}
@override
Widget build(BuildContext context) {
final nodesById = {for (final n in widget.nodes) n.id: n};
return Stack(
children: [
InteractiveViewer(
transformationController: _transform,
constrained: false,
minScale: 0.35,
maxScale: 2.0,
panEnabled: _draggingId == null,
scaleEnabled: _draggingId == null,
boundaryMargin: const EdgeInsets.all(600),
child: GestureDetector(
// Tap on empty canvas cancels a pending connection.
onTap: () => setState(() => _connectingFrom = null),
child: SizedBox(
width: widget.canvasSize.width,
height: widget.canvasSize.height,
child: Stack(
children: [
Positioned.fill(
child: CustomPaint(
painter: _GraphPainter(
nodesById: nodesById,
edges: widget.edges,
),
),
),
for (final node in widget.nodes)
Positioned(
left: node.position.dx,
top: node.position.dy,
// Raw pointer events instead of a pan GestureDetector:
// no gesture-arena delay, no drag slop — the node
// follows the cursor from the first pixel.
child: Listener(
onPointerDown: widget.onNodeMoved == null
? null
: (_) => setState(() => _draggingId = node.id),
onPointerMove: widget.onNodeMoved == null
? null
: (event) {
if (_draggingId != node.id) return;
widget.onNodeMoved!(
node.id,
node.position + event.delta / _scale,
);
},
onPointerUp: (_) {
if (_draggingId == node.id) {
setState(() => _draggingId = null);
widget.onNodeDragEnd?.call(node.id);
}
},
onPointerCancel: (_) {
if (_draggingId == node.id) {
setState(() => _draggingId = null);
}
},
child: _NodeCard(
node: node,
isConnectSource: _connectingFrom == node.id,
connectMode: _connectingFrom != null,
showLinkButton: widget.onConnect != null,
onTap: () => _handleNodeBodyTap(node.id),
onLongPress: widget.onNodeLongPress == null
? null
: () => widget.onNodeLongPress!(node.id),
onStartConnect: () => setState(() {
_connectingFrom = _connectingFrom == node.id
? null
: node.id;
}),
),
),
),
],
),
),
),
),
if (_connectingFrom != null)
Positioned(
top: 12,
left: 0,
right: 0,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 7,
),
decoration: BoxDecoration(
color: AppColors.accentMuted,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.accentBorder),
),
child: Text(
'TAP A NODE TO CONNECT · TAP CANVAS TO CANCEL',
style: GoogleFonts.jetBrainsMono(
fontSize: 10,
fontWeight: FontWeight.w600,
letterSpacing: 1,
color: AppColors.accent,
),
),
),
),
),
],
);
}
}
// ---------------------------------------------------------------------------
// Node card
// ---------------------------------------------------------------------------
class _NodeCard extends StatelessWidget {
const _NodeCard({
required this.node,
required this.isConnectSource,
required this.connectMode,
required this.showLinkButton,
required this.onTap,
this.onLongPress,
required this.onStartConnect,
});
final GraphNodeData node;
final bool isConnectSource;
final bool connectMode;
final bool showLinkButton;
final VoidCallback onTap;
final VoidCallback? onLongPress;
final VoidCallback onStartConnect;
@override
Widget build(BuildContext context) {
final borderColor = isConnectSource
? node.accent
: connectMode
? node.accent.withValues(alpha: 0.4)
: AppColors.border;
return GestureDetector(
onTap: onTap,
onLongPress: onLongPress,
child: Container(
width: NodeGraphCanvas.nodeWidth,
height: NodeGraphCanvas.nodeHeight,
decoration: BoxDecoration(
color: AppColors.surfaceContainer,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: borderColor,
width: isConnectSource ? 2 : 1,
),
boxShadow: isConnectSource
? [
BoxShadow(
color: node.accent.withValues(alpha: 0.35),
blurRadius: 16,
),
]
: null,
),
child: Row(
children: [
Container(
width: 4,
margin: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: node.accent,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
node.title.toUpperCase(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.chakraPetch(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
color: AppColors.textPrimary,
),
),
if (node.subtitle != null && node.subtitle!.isNotEmpty)
Text(
node.subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.jetBrainsMono(
fontSize: 9,
color: AppColors.textMuted,
),
),
],
),
),
if (showLinkButton)
GestureDetector(
onTap: onStartConnect,
child: Container(
width: 26,
height: double.infinity,
color: Colors.transparent,
child: Icon(
Icons.trending_flat_rounded,
size: 16,
color: isConnectSource ? node.accent : AppColors.textMuted,
),
),
),
const SizedBox(width: 2),
],
),
),
);
}
}
// ---------------------------------------------------------------------------
// Edges + dot grid painter
// ---------------------------------------------------------------------------
class _GraphPainter extends CustomPainter {
_GraphPainter({required this.nodesById, required this.edges});
final Map<String, GraphNodeData> nodesById;
final List<GraphEdgeData> edges;
@override
void paint(Canvas canvas, Size size) {
_paintDotGrid(canvas, size);
final edgePaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2;
for (final edge in edges) {
final from = nodesById[edge.fromId];
final to = nodesById[edge.toId];
if (from == null || to == null) continue;
final start =
from.position +
const Offset(
NodeGraphCanvas.nodeWidth,
NodeGraphCanvas.nodeHeight / 2,
);
final end = to.position + const Offset(0, NodeGraphCanvas.nodeHeight / 2);
edgePaint.color = from.accent.withValues(alpha: 0.75);
final dx = ((end.dx - start.dx).abs() * 0.5).clamp(40.0, 160.0);
final path = Path()
..moveTo(start.dx, start.dy)
..cubicTo(
start.dx + dx,
start.dy,
end.dx - dx,
end.dy,
end.dx - 7,
end.dy,
);
canvas.drawPath(path, edgePaint);
// Arrowhead
final arrow = Path()
..moveTo(end.dx, end.dy)
..lineTo(end.dx - 9, end.dy - 5)
..lineTo(end.dx - 9, end.dy + 5)
..close();
canvas.drawPath(
arrow,
Paint()..color = from.accent.withValues(alpha: 0.9),
);
}
}
void _paintDotGrid(Canvas canvas, Size size) {
const step = 28.0;
final points = <Offset>[
for (double x = step; x < size.width; x += step)
for (double y = step; y < size.height; y += step) Offset(x, y),
];
canvas.drawPoints(
PointMode.points,
points,
Paint()
..color = AppColors.surfaceContainerHigh
..strokeWidth = 1.6
..strokeCap = StrokeCap.round,
);
}
@override
bool shouldRepaint(_GraphPainter oldDelegate) => true;
}

View File

@@ -16,7 +16,7 @@ class NextWorkoutBanner extends StatelessWidget {
decoration: BoxDecoration(
color: AppColors.surfaceContainer,
borderRadius: UIConstants.cardBorderRadius,
border: Border.all(color: AppColors.border),
border: Border.all(color: AppColors.accentBorder),
),
child: Row(
children: [
@@ -24,12 +24,13 @@ class NextWorkoutBanner extends StatelessWidget {
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.accentMuted,
color: AppColors.accent,
borderRadius: UIConstants.smallCardBorderRadius,
boxShadow: UIConstants.accentGlow,
),
child: const Icon(
Icons.play_arrow_rounded,
color: AppColors.accent,
color: AppColors.zinc950,
size: 22,
),
),
@@ -39,30 +40,28 @@ class NextWorkoutBanner extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Up Next',
style: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.textMuted,
'UP NEXT',
style: GoogleFonts.jetBrainsMono(
fontSize: 10,
fontWeight: FontWeight.w600,
letterSpacing: 1.5,
color: AppColors.accent,
),
),
const SizedBox(height: 2),
Text(
workoutName,
style: GoogleFonts.inter(
workoutName.toUpperCase(),
style: GoogleFonts.chakraPetch(
fontSize: 15,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
color: AppColors.textPrimary,
),
),
],
),
),
const Icon(
Icons.chevron_right,
color: AppColors.textMuted,
size: 20,
),
const Icon(Icons.chevron_right, color: AppColors.textMuted, size: 20),
],
),
);

View File

@@ -14,11 +14,12 @@ class WelcomeHeader extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Welcome back',
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textMuted,
'WELCOME BACK',
style: GoogleFonts.jetBrainsMono(
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 2,
color: AppColors.accent,
),
),
const SizedBox(height: UIConstants.spacing4),
@@ -26,10 +27,11 @@ class WelcomeHeader extends StatelessWidget {
children: [
Expanded(
child: Text(
programName,
style: GoogleFonts.inter(
programName.toUpperCase(),
style: GoogleFonts.chakraPetch(
fontSize: 28,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
color: AppColors.textPrimary,
),
),
@@ -42,21 +44,23 @@ class WelcomeHeader extends StatelessWidget {
decoration: BoxDecoration(
color: AppColors.accentMuted,
borderRadius: UIConstants.smallCardBorderRadius,
border: Border.all(color: AppColors.accentBorder),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.fitness_center,
Icons.sports_martial_arts,
size: 14,
color: AppColors.accent,
),
const SizedBox(width: 6),
Text(
'Active Program',
style: GoogleFonts.inter(
fontSize: 12,
'ACTIVE PROGRAM',
style: GoogleFonts.jetBrainsMono(
fontSize: 10,
fontWeight: FontWeight.w600,
letterSpacing: 1.5,
color: AppColors.accent,
),
),

View File

@@ -5,18 +5,28 @@ 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/presentation/plan_editor/plan_editor_controller.dart';
import 'package:trainhub_flutter/presentation/plan_editor/widgets/plan_flow_graph.dart';
import 'package:trainhub_flutter/presentation/plan_editor/widgets/plan_section_card.dart';
@RoutePage()
class PlanEditorPage extends ConsumerWidget {
class PlanEditorPage extends ConsumerStatefulWidget {
final String planId;
const PlanEditorPage({super.key, @PathParam('planId') required this.planId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(planEditorControllerProvider(planId));
final controller = ref.read(planEditorControllerProvider(planId).notifier);
ConsumerState<PlanEditorPage> createState() => _PlanEditorPageState();
}
class _PlanEditorPageState extends ConsumerState<PlanEditorPage> {
bool _graphView = false;
@override
Widget build(BuildContext context) {
final state = ref.watch(planEditorControllerProvider(widget.planId));
final controller = ref.read(
planEditorControllerProvider(widget.planId).notifier,
);
return Scaffold(
backgroundColor: AppColors.surface,
@@ -104,6 +114,13 @@ class PlanEditorPage extends ConsumerWidget {
),
),
// List / graph view toggle
_ViewToggle(
graphView: _graphView,
onChanged: (v) => setState(() => _graphView = v),
),
const SizedBox(width: UIConstants.spacing16),
// Unsaved changes badge + save button
state.maybeWhen(
data: (data) => data.isDirty
@@ -115,7 +132,9 @@ class PlanEditorPage extends ConsumerWidget {
vertical: 4,
),
decoration: BoxDecoration(
color: AppColors.warning.withValues(alpha: 0.12),
color: AppColors.warning.withValues(
alpha: 0.12,
),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.warning.withValues(
@@ -150,41 +169,50 @@ class PlanEditorPage extends ConsumerWidget {
// --- Body ---
Expanded(
child: state.when(
data: (data) => ReorderableListView.builder(
padding: const EdgeInsets.all(UIConstants.spacing24),
onReorder: controller.reorderSection,
footer: Center(
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: UIConstants.spacing16,
),
child: OutlinedButton.icon(
onPressed: controller.addSection,
icon: const Icon(Icons.add, size: 16),
label: const Text('Add Section'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.textSecondary,
side: const BorderSide(color: AppColors.border),
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing24,
vertical: UIConstants.spacing12,
data: (data) => _graphView
? PlanFlowGraph(
// Rebuild the graph when the plan structure changes.
key: ValueKey(
'${data.plan.id}-${data.plan.totalExercises}-'
'${data.plan.sections.length}',
),
plan: data.plan,
)
: ReorderableListView.builder(
padding: const EdgeInsets.all(UIConstants.spacing24),
onReorder: controller.reorderSection,
footer: Center(
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: UIConstants.spacing16,
),
child: OutlinedButton.icon(
onPressed: controller.addSection,
icon: const Icon(Icons.add, size: 16),
label: const Text('Add Section'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.textSecondary,
side: const BorderSide(color: AppColors.border),
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing24,
vertical: UIConstants.spacing12,
),
),
),
),
),
itemCount: data.plan.sections.length,
itemBuilder: (context, index) {
final section = data.plan.sections[index];
return PlanSectionCard(
key: ValueKey(section.id),
section: section,
sectionIndex: index,
plan: data.plan,
availableExercises: data.availableExercises,
);
},
),
),
),
itemCount: data.plan.sections.length,
itemBuilder: (context, index) {
final section = data.plan.sections[index];
return PlanSectionCard(
key: ValueKey(section.id),
section: section,
sectionIndex: index,
plan: data.plan,
availableExercises: data.availableExercises,
);
},
),
error: (e, s) => Center(
child: Text(
'Error: $e',
@@ -199,3 +227,77 @@ class PlanEditorPage extends ConsumerWidget {
);
}
}
// ---------------------------------------------------------------------------
// List / graph segmented toggle
// ---------------------------------------------------------------------------
class _ViewToggle extends StatelessWidget {
const _ViewToggle({required this.graphView, required this.onChanged});
final bool graphView;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_ToggleButton(
icon: Icons.view_list_rounded,
tooltip: 'List view',
selected: !graphView,
onTap: () => onChanged(false),
),
_ToggleButton(
icon: Icons.account_tree_outlined,
tooltip: 'Graph view',
selected: graphView,
onTap: () => onChanged(true),
),
],
),
);
}
}
class _ToggleButton extends StatelessWidget {
const _ToggleButton({
required this.icon,
required this.tooltip,
required this.selected,
required this.onTap,
});
final IconData icon;
final String tooltip;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
decoration: BoxDecoration(
color: selected ? AppColors.accentMuted : Colors.transparent,
borderRadius: BorderRadius.circular(7),
),
child: Icon(
icon,
size: 16,
color: selected ? AppColors.accent : AppColors.textMuted,
),
),
),
);
}
}

View File

@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
import 'package:trainhub_flutter/presentation/common/widgets/node_graph_canvas.dart';
/// Graph view of a training plan: one column per section, exercises chained
/// top-to-bottom, sections chained left-to-right — the session flow at a
/// glance. Nodes can be dragged to rearrange the picture; the plan structure
/// itself is edited in the list view.
class PlanFlowGraph extends StatefulWidget {
const PlanFlowGraph({super.key, required this.plan});
final TrainingPlanEntity plan;
@override
State<PlanFlowGraph> createState() => _PlanFlowGraphState();
}
class _PlanFlowGraphState extends State<PlanFlowGraph> {
/// User-dragged overrides on top of the auto layout, per node id.
final Map<String, Offset> _overrides = {};
static const double _colWidth = 250;
static const double _rowHeight = 84;
@override
Widget build(BuildContext context) {
final nodes = <GraphNodeData>[];
final edges = <GraphEdgeData>[];
Offset pos(String id, Offset fallback) => _overrides[id] ?? fallback;
nodes.add(
GraphNodeData(
id: 'start',
title: 'Start Session',
subtitle: widget.plan.name,
accent: AppColors.success,
position: pos('start', const Offset(40, 40)),
),
);
var previousTail = 'start';
for (var s = 0; s < widget.plan.sections.length; s++) {
final section = widget.plan.sections[s];
final x = 40.0 + (s + 1) * _colWidth;
final sectionId = 'sec-${section.id}';
nodes.add(
GraphNodeData(
id: sectionId,
title: section.name,
subtitle: '${section.exercises.length} drills',
accent: AppColors.accent,
position: pos(sectionId, Offset(x, 40)),
),
);
edges.add(GraphEdgeData(previousTail, sectionId));
var tail = sectionId;
for (var e = 0; e < section.exercises.length; e++) {
final exercise = section.exercises[e];
final id = 'ex-${exercise.instanceId}';
final detail = exercise.isTime
? '${exercise.sets} × ${exercise.value}s · rest ${exercise.rest}s'
: '${exercise.sets} × ${exercise.value} · rest ${exercise.rest}s';
nodes.add(
GraphNodeData(
id: id,
title: exercise.name,
subtitle: detail,
accent: AppColors.info,
position: pos(id, Offset(x + 24, 40 + (e + 1) * _rowHeight)),
),
);
edges.add(GraphEdgeData(tail, id));
tail = id;
}
previousTail = tail;
}
return Stack(
children: [
ClipRect(
child: NodeGraphCanvas(
nodes: nodes,
edges: edges,
canvasSize: Size(
(widget.plan.sections.length + 2) * _colWidth + 600,
2000,
),
onNodeMoved: (id, position) =>
setState(() => _overrides[id] = position),
),
),
Positioned(
left: 16,
bottom: 16,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: AppColors.surfaceContainer.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.border),
),
child: Text(
'SESSION FLOW · DRAG TO REARRANGE · EDIT IN LIST VIEW',
style: GoogleFonts.jetBrainsMono(
fontSize: 9,
letterSpacing: 0.8,
color: AppColors.textMuted,
),
),
),
),
],
);
}
}

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:archive/archive_io.dart';
@@ -27,12 +28,24 @@ Future<String> _llamaArchiveUrl() async {
throw UnsupportedError('Unsupported platform: ${Platform.operatingSystem}');
}
@riverpod
// keepAlive: a download must survive navigating away from the settings /
// welcome screen — otherwise leaving the page silently drops its progress.
@Riverpod(keepAlive: true)
class AiModelSettingsController extends _$AiModelSettingsController {
final _dio = Dio();
@override
AiModelSettingsState build() => const AiModelSettingsState();
AiModelSettingsState build() {
// Check installed files right away so every screen (settings, welcome,
// chat) sees the real state without a manual "Validate" click.
Future.microtask(validateModels);
return const AiModelSettingsState();
}
/// A file that exists but is suspiciously small is a leftover from an
/// interrupted download — treat it as missing.
static bool _isComplete(File file, int minBytes) =>
file.existsSync() && file.lengthSync() >= minBytes;
Future<void> validateModels() async {
state = state.copyWith(
@@ -42,13 +55,19 @@ class AiModelSettingsController extends _$AiModelSettingsController {
try {
final dir = await getApplicationDocumentsDirectory();
final base = dir.path;
final serverBin = File(p.join(base, AiConstants.serverBinaryName));
final nomicModel = File(p.join(base, AiConstants.nomicModelFile));
final qwenModel = File(p.join(base, AiConstants.qwenModelFile));
final validated =
serverBin.existsSync() &&
nomicModel.existsSync() &&
qwenModel.existsSync();
_isComplete(
File(p.join(base, AiConstants.serverBinaryName)),
AiConstants.serverBinaryMinBytes,
) &&
_isComplete(
File(p.join(base, AiConstants.nomicModelFile)),
AiConstants.nomicModelMinBytes,
) &&
_isComplete(
File(p.join(base, AiConstants.qwenModelFile)),
AiConstants.qwenModelMinBytes,
);
state = state.copyWith(
areModelsValidated: validated,
currentTask: validated ? 'All files present.' : 'Files missing.',
@@ -97,14 +116,16 @@ class AiModelSettingsController extends _$AiModelSettingsController {
savePath: p.join(dir.path, AiConstants.nomicModelFile),
taskLabel: 'Downloading Nomic embedding model…',
overallStart: 0.2,
overallEnd: 0.55,
overallEnd: 0.35,
minBytes: AiConstants.nomicModelMinBytes,
);
await _downloadFile(
url: AiConstants.qwenModelUrl,
savePath: p.join(dir.path, AiConstants.qwenModelFile),
taskLabel: 'Downloading Qwen 2.5 7B model…',
overallStart: 0.55,
taskLabel: 'Downloading Qwen3 4B model…',
overallStart: 0.35,
overallEnd: 1.0,
minBytes: AiConstants.qwenModelMinBytes,
);
state = state.copyWith(
isDownloading: false,
@@ -112,6 +133,11 @@ class AiModelSettingsController extends _$AiModelSettingsController {
currentTask: 'Download complete.',
);
await validateModels();
// Bring the AI online immediately — the user shouldn't have to visit
// the chat page or click anything for the servers to start loading.
if (state.areModelsValidated) {
unawaited(di.getIt<AiProcessManager>().startServers());
}
} on DioException catch (e) {
state = state.copyWith(
isDownloading: false,
@@ -133,11 +159,23 @@ class AiModelSettingsController extends _$AiModelSettingsController {
required String taskLabel,
required double overallStart,
required double overallEnd,
int minBytes = 0,
}) async {
// Already downloaded (e.g. a retry after a failure further down the
// list) — don't pull gigabytes again.
if (_isComplete(File(savePath), minBytes) && minBytes > 0) {
state = state.copyWith(progress: overallEnd);
return;
}
state = state.copyWith(currentTask: taskLabel, progress: overallStart);
// Download to a temp file and rename at the end, so an interrupted
// download can never masquerade as a complete model file.
final partPath = '$savePath.part';
await _dio.download(
url,
savePath,
partPath,
onReceiveProgress: (received, total) {
if (total <= 0) return;
final fileProgress = received / total;
@@ -151,6 +189,10 @@ class AiModelSettingsController extends _$AiModelSettingsController {
receiveTimeout: const Duration(hours: 2),
),
);
final target = File(savePath);
if (target.existsSync()) target.deleteSync();
File(partPath).renameSync(savePath);
}
Future<void> _extractBinary(String archivePath, String destDir) async {

View File

@@ -7,15 +7,12 @@ part of 'ai_model_settings_controller.dart';
// **************************************************************************
String _$aiModelSettingsControllerHash() =>
r'27a37c3fafb21b93a8b5523718f1537419bd382a';
r'41d0842f57085bab80f0de18d8ab0eb72cdd5440';
/// See also [AiModelSettingsController].
@ProviderFor(AiModelSettingsController)
final aiModelSettingsControllerProvider =
AutoDisposeNotifierProvider<
AiModelSettingsController,
AiModelSettingsState
>.internal(
NotifierProvider<AiModelSettingsController, AiModelSettingsState>.internal(
AiModelSettingsController.new,
name: r'aiModelSettingsControllerProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
@@ -25,6 +22,6 @@ final aiModelSettingsControllerProvider =
allTransitiveDependencies: null,
);
typedef _$AiModelSettingsController = AutoDisposeNotifier<AiModelSettingsState>;
typedef _$AiModelSettingsController = Notifier<AiModelSettingsState>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -53,10 +53,7 @@ class KnowledgeBaseController extends _$KnowledgeBaseController {
successMessage: 'Saved! Knowledge base now has $count chunks.',
);
} catch (e) {
state = state.copyWith(
isLoading: false,
errorMessage: _friendlyError(e),
);
state = state.copyWith(isLoading: false, errorMessage: _friendlyError(e));
}
}
@@ -78,10 +75,7 @@ class KnowledgeBaseController extends _$KnowledgeBaseController {
successMessage: 'Knowledge base cleared.',
);
} catch (e) {
state = state.copyWith(
isLoading: false,
errorMessage: _friendlyError(e),
);
state = state.copyWith(isLoading: false, errorMessage: _friendlyError(e));
}
}
@@ -91,8 +85,7 @@ class KnowledgeBaseController extends _$KnowledgeBaseController {
String _friendlyError(Object e) {
final msg = e.toString();
if (msg.contains('Connection refused') ||
msg.contains('SocketException')) {
if (msg.contains('Connection refused') || msg.contains('SocketException')) {
return 'Cannot reach the embedding server. '
'Make sure AI models are downloaded and the app has had time to '
'start the inference servers.';

View File

@@ -91,8 +91,10 @@ class _KnowledgeBasePageState extends ConsumerState<KnowledgeBasePage> {
final controller = ref.read(knowledgeBaseControllerProvider.notifier);
// Show success SnackBar when a note is saved successfully.
ref.listen<KnowledgeBaseState>(knowledgeBaseControllerProvider,
(prev, next) {
ref.listen<KnowledgeBaseState>(knowledgeBaseControllerProvider, (
prev,
next,
) {
if (next.successMessage != null &&
next.successMessage != prev?.successMessage) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -107,12 +109,10 @@ class _KnowledgeBasePageState extends ConsumerState<KnowledgeBasePage> {
backgroundColor: AppColors.surfaceContainerHigh,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(UIConstants.smallBorderRadius),
side: const BorderSide(
color: AppColors.success,
width: 1,
borderRadius: BorderRadius.circular(
UIConstants.smallBorderRadius,
),
side: const BorderSide(color: AppColors.success, width: 1),
),
duration: const Duration(seconds: 3),
),
@@ -139,12 +139,12 @@ class _KnowledgeBasePageState extends ConsumerState<KnowledgeBasePage> {
children: [
// Heading
Text(
'Knowledge Base',
style: GoogleFonts.inter(
'KNOWLEDGE BASE',
style: GoogleFonts.chakraPetch(
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
letterSpacing: -0.3,
letterSpacing: 1,
),
),
const SizedBox(height: UIConstants.spacing8),
@@ -229,9 +229,7 @@ class _KnowledgeBasePageState extends ConsumerState<KnowledgeBasePage> {
),
if (kbState.chunkCount > 0) ...[
const SizedBox(width: UIConstants.spacing12),
_ClearButton(
onPressed: () => _clear(controller),
),
_ClearButton(onPressed: () => _clear(controller)),
],
],
),
@@ -327,9 +325,9 @@ class _StatusCard extends StatelessWidget {
child: Text(
hasChunks
? '$chunkCount chunk${chunkCount == 1 ? '' : 's'} stored — '
'AI chat will use these as context.'
'AI chat will use these as context.'
: 'No notes added yet. The AI chat will use only its base '
'training knowledge.',
'training knowledge.',
style: GoogleFonts.inter(
fontSize: 13,
color: hasChunks ? AppColors.success : AppColors.textMuted,
@@ -407,8 +405,7 @@ class _SaveButtonState extends State<_SaveButton> {
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius:
BorderRadius.circular(UIConstants.smallBorderRadius),
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
onTap: widget.onPressed,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -471,8 +468,7 @@ class _ClearButtonState extends State<_ClearButton> {
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(
@@ -484,8 +480,9 @@ class _ClearButtonState extends State<_ClearButton> {
Icon(
Icons.delete_outline_rounded,
size: 15,
color:
_hovered ? AppColors.destructive : AppColors.textMuted,
color: _hovered
? AppColors.destructive
: AppColors.textMuted,
),
const SizedBox(width: UIConstants.spacing8),
Text(
@@ -523,9 +520,7 @@ class _ErrorBanner extends StatelessWidget {
decoration: BoxDecoration(
color: AppColors.destructiveMuted,
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
border: Border.all(
color: AppColors.destructive.withValues(alpha: 0.4),
),
border: Border.all(color: AppColors.destructive.withValues(alpha: 0.4)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -589,7 +584,8 @@ class _HowItWorksCard extends StatelessWidget {
const SizedBox(height: UIConstants.spacing12),
_Step(
n: '1',
text: 'Your text is split into ~500-character chunks at paragraph '
text:
'Your text is split into ~500-character chunks at paragraph '
'and sentence boundaries.',
),
const SizedBox(height: UIConstants.spacing8),

View File

@@ -7,6 +7,7 @@ import 'package:trainhub_flutter/core/router/app_router.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/presentation/settings/ai_model_settings_controller.dart';
import 'package:trainhub_flutter/presentation/settings/widgets/ai_models_section.dart';
import 'package:trainhub_flutter/presentation/settings/widgets/ai_provider_section.dart';
import 'package:trainhub_flutter/presentation/settings/widgets/knowledge_base_section.dart';
import 'package:trainhub_flutter/presentation/settings/widgets/settings_top_bar.dart';
@@ -34,15 +35,17 @@ class SettingsPage extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Settings',
style: GoogleFonts.inter(
'SETTINGS',
style: GoogleFonts.chakraPetch(
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
letterSpacing: -0.3,
letterSpacing: 1,
),
),
const SizedBox(height: UIConstants.spacing32),
const AiProviderSection(),
const SizedBox(height: UIConstants.spacing32),
AiModelsSection(
modelState: modelState,
onDownload: controller.downloadAll,

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,
),
),
),

View File

@@ -1,10 +1,12 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trainhub_flutter/core/constants/app_constants.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/router/app_router.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/presentation/settings/ai_model_settings_controller.dart';
@RoutePage()
class ShellPage extends StatelessWidget {
@@ -113,21 +115,22 @@ class _Sidebar extends StatelessWidget {
decoration: BoxDecoration(
color: AppColors.accentMuted,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.accentBorder),
),
child: const Icon(
Icons.fitness_center,
Icons.sports_martial_arts,
color: AppColors.accent,
size: 18,
),
),
const SizedBox(width: 10),
Text(
'TrainHub',
style: GoogleFonts.inter(
'TRAINHUB',
style: GoogleFonts.chakraPetch(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
letterSpacing: -0.3,
letterSpacing: 1.5,
),
),
],
@@ -150,6 +153,9 @@ class _Sidebar extends StatelessWidget {
const Spacer(),
// --- AI model download progress (runs in the background) ---
const _DownloadIndicator(),
// --- Footer ---
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
@@ -206,10 +212,10 @@ class _NavItemState extends State<_NavItem> {
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
decoration: BoxDecoration(
color: active
? AppColors.zinc800
? AppColors.accentMuted
: _isHovered
? AppColors.zinc900
: Colors.transparent,
? AppColors.surfaceContainerHigh
: Colors.transparent,
borderRadius: BorderRadius.circular(6),
),
child: Row(
@@ -229,19 +235,20 @@ class _NavItemState extends State<_NavItem> {
Icon(
active ? widget.data.activeIcon : widget.data.icon,
size: 17,
color: active ? AppColors.textPrimary : AppColors.textMuted,
color: active ? AppColors.accent : AppColors.textMuted,
),
const SizedBox(width: 9),
Text(
widget.data.label,
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: active ? FontWeight.w600 : FontWeight.w400,
widget.data.label.toUpperCase(),
style: GoogleFonts.chakraPetch(
fontSize: 12,
fontWeight: active ? FontWeight.w600 : FontWeight.w500,
letterSpacing: 1.2,
color: active
? AppColors.textPrimary
: _isHovered
? AppColors.textSecondary
: AppColors.textMuted,
? AppColors.textSecondary
: AppColors.textMuted,
),
),
],
@@ -252,6 +259,61 @@ class _NavItemState extends State<_NavItem> {
}
}
// ---------------------------------------------------------------------------
// AI model download progress — visible from anywhere while a download runs
// ---------------------------------------------------------------------------
class _DownloadIndicator extends ConsumerWidget {
const _DownloadIndicator();
@override
Widget build(BuildContext context, WidgetRef ref) {
final modelState = ref.watch(aiModelSettingsControllerProvider);
if (!modelState.isDownloading) return const SizedBox.shrink();
return Container(
margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.accentMuted,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.accentBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'DOWNLOADING AI',
style: GoogleFonts.jetBrainsMono(
fontSize: 9,
fontWeight: FontWeight.w600,
letterSpacing: 1.2,
color: AppColors.accent,
),
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: modelState.progress,
minHeight: 4,
backgroundColor: AppColors.surfaceContainerHigh,
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.accent),
),
),
const SizedBox(height: 4),
Text(
'${(modelState.progress * 100).toStringAsFixed(0)}%',
style: GoogleFonts.jetBrainsMono(
fontSize: 10,
color: AppColors.textSecondary,
),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Settings icon button in sidebar footer
// ---------------------------------------------------------------------------

View File

@@ -14,6 +14,7 @@ import 'package:trainhub_flutter/presentation/trainings/trainings_controller.dar
import 'package:trainhub_flutter/presentation/common/widgets/app_empty_state.dart';
import 'package:trainhub_flutter/presentation/common/dialogs/text_input_dialog.dart';
import 'package:trainhub_flutter/presentation/common/dialogs/confirm_dialog.dart';
import 'package:trainhub_flutter/presentation/trainings/widgets/combo_builder_tab.dart';
@RoutePage()
class TrainingsPage extends ConsumerWidget {
@@ -24,7 +25,7 @@ class TrainingsPage extends ConsumerWidget {
final asyncState = ref.watch(trainingsControllerProvider);
return DefaultTabController(
length: 2,
length: 3,
child: Column(
children: [
Container(
@@ -34,8 +35,9 @@ class TrainingsPage extends ConsumerWidget {
),
child: const TabBar(
tabs: [
Tab(text: 'Training Plans'),
Tab(text: 'Exercises'),
Tab(text: 'TRAINING PLANS'),
Tab(text: 'EXERCISES'),
Tab(text: 'COMBOS'),
],
),
),
@@ -47,6 +49,7 @@ class TrainingsPage extends ConsumerWidget {
children: [
_PlansTab(plans: state.plans, ref: ref),
_ExercisesTab(exercises: state.exercises, ref: ref),
const ComboBuilderTab(),
],
),
),
@@ -401,7 +404,8 @@ class _ExerciseListItemState extends State<_ExerciseListItem> {
@override
Widget build(BuildContext context) {
final hasVideo = widget.exercise.videoUrl != null &&
final hasVideo =
widget.exercise.videoUrl != null &&
widget.exercise.videoUrl!.isNotEmpty;
return MouseRegion(
@@ -542,8 +546,7 @@ class _ExercisePreviewDialog extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (hasVideo)
_ExerciseVideoPreview(videoPath: exercise.videoUrl!),
if (hasVideo) _ExerciseVideoPreview(videoPath: exercise.videoUrl!),
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.all(UIConstants.spacing24),
@@ -578,8 +581,7 @@ class _ExercisePreviewDialog extends StatelessWidget {
],
),
],
if (exercise.tags != null &&
exercise.tags!.isNotEmpty) ...[
if (exercise.tags != null && exercise.tags!.isNotEmpty) ...[
const SizedBox(height: UIConstants.spacing12),
Wrap(
spacing: 6,
@@ -740,8 +742,8 @@ class _ExerciseVideoPreviewState extends State<_ExerciseVideoPreview> {
_player
.seek(Duration(milliseconds: (_clipStart * 1000).round()))
.then((_) {
if (mounted) setState(() => _isInitialized = true);
});
if (mounted) setState(() => _isInitialized = true);
});
} else {
if (mounted) setState(() => _isInitialized = true);
}
@@ -827,7 +829,9 @@ class _ExerciseVideoPreviewState extends State<_ExerciseVideoPreview> {
final hasClip = _clipEnd != double.infinity;
final clipDuration = hasClip ? (_clipEnd - _clipStart) : 0.0;
final clipPosition = (_position - _clipStart).clamp(0.0, clipDuration);
final progress = (hasClip && clipDuration > 0) ? clipPosition / clipDuration : 0.0;
final progress = (hasClip && clipDuration > 0)
? clipPosition / clipDuration
: 0.0;
return Column(
mainAxisSize: MainAxisSize.min,

View File

@@ -0,0 +1,805 @@
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/combo_service.dart';
import 'package:trainhub_flutter/domain/entities/exercise.dart';
import 'package:trainhub_flutter/domain/entities/training_exercise.dart';
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
import 'package:trainhub_flutter/domain/entities/training_section.dart';
import 'package:trainhub_flutter/domain/repositories/exercise_repository.dart';
import 'package:trainhub_flutter/domain/repositories/training_plan_repository.dart';
import 'package:trainhub_flutter/injection.dart';
import 'package:trainhub_flutter/presentation/common/widgets/node_graph_canvas.dart';
import 'package:trainhub_flutter/presentation/common/dialogs/text_input_dialog.dart';
import 'package:uuid/uuid.dart';
const _uuid = Uuid();
/// Node-graph editor for technique combinations: nodes are techniques,
/// edges are the transitions that flow well between them. A random walk
/// over the graph generates combos to drill.
class ComboBuilderTab extends StatefulWidget {
const ComboBuilderTab({super.key});
@override
State<ComboBuilderTab> createState() => _ComboBuilderTabState();
}
class _ComboBuilderTabState extends State<ComboBuilderTab> {
late final ComboService _service = getIt<ComboService>();
String? _selectedId;
@override
void initState() {
super.initState();
_service.addListener(_onServiceChanged);
_service.ensureLoaded();
}
@override
void dispose() {
_service.removeListener(_onServiceChanged);
super.dispose();
}
void _onServiceChanged() {
if (mounted) setState(() {});
}
Combo? get _selected {
for (final c in _service.combos) {
if (c.id == _selectedId) return c;
}
return _service.combos.isEmpty ? null : _service.combos.first;
}
// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------
Future<void> _createCombo() async {
final name = await TextInputDialog.show(
context,
title: 'New Combo Graph',
hintText: 'e.g. Sparring counters',
confirmLabel: 'Create',
);
if (name == null || name.trim().isEmpty) return;
final combo = _service.create(name.trim());
setState(() => _selectedId = combo.id);
}
Future<void> _addTechnique(Combo combo) async {
final technique = await _showTechniquePicker();
if (technique == null) return;
// Drop new nodes in a loose diagonal so they don't stack exactly.
final i = combo.nodes.length;
final node = ComboNode(
id: _uuid.v4(),
technique: technique.$1,
translation: technique.$2,
dx: 80.0 + (i % 5) * 210.0,
dy: 80.0 + (i ~/ 5) * 110.0 + (i % 3) * 14.0,
);
_service.update(combo.copyWith(nodes: [...combo.nodes, node]));
}
void _toggleEdge(Combo combo, String fromId, String toId) {
final exists = combo.edges.any((e) => e.fromId == fromId && e.toId == toId);
final edges = exists
? combo.edges
.where((e) => !(e.fromId == fromId && e.toId == toId))
.toList()
: [...combo.edges, ComboEdge(fromId, toId)];
_service.update(combo.copyWith(edges: edges));
}
void _moveNode(Combo combo, String id, Offset position) {
_service.update(
combo.copyWith(
nodes: [
for (final n in combo.nodes)
n.id == id ? n.copyWith(dx: position.dx, dy: position.dy) : n,
],
),
persist: false,
);
}
Future<void> _deleteNode(Combo combo, String id) async {
final node = combo.nodes.where((n) => n.id == id).firstOrNull;
if (node == null) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(
'Remove "${node.technique}"?',
style: GoogleFonts.inter(fontSize: 15, fontWeight: FontWeight.w600),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(
'Remove',
style: GoogleFonts.inter(color: AppColors.destructive),
),
),
],
),
);
if (confirmed != true) return;
_service.update(
combo.copyWith(
nodes: combo.nodes.where((n) => n.id != id).toList(),
edges: combo.edges
.where((e) => e.fromId != id && e.toId != id)
.toList(),
),
);
}
void _generate(Combo combo) {
showDialog<void>(
context: context,
builder: (ctx) => _GeneratedComboDialog(combo: combo),
);
}
Future<(String, String)?> _showTechniquePicker() {
return showDialog<(String, String)>(
context: context,
builder: (ctx) => const _TechniquePickerDialog(),
);
}
// ---------------------------------------------------------------------------
// Build
// ---------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
final combo = _selected;
return Row(
children: [
_ComboListPanel(
combos: _service.combos,
selectedId: combo?.id,
onSelect: (id) => setState(() => _selectedId = id),
onCreate: _createCombo,
onDelete: (id) => _service.delete(id),
),
const VerticalDivider(width: 1, color: AppColors.border),
Expanded(
child: combo == null
? _EmptyState(onCreate: _createCombo)
: Column(
children: [
_Toolbar(
combo: combo,
onAddTechnique: () => _addTechnique(combo),
onGenerate: combo.nodes.isEmpty
? null
: () => _generate(combo),
),
Expanded(
child: ClipRect(
child: NodeGraphCanvas(
nodes: [
for (final n in combo.nodes)
GraphNodeData(
id: n.id,
title: n.technique,
subtitle: n.translation,
accent: _accentFor(n.technique),
position: Offset(n.dx, n.dy),
),
],
edges: [
for (final e in combo.edges)
GraphEdgeData(e.fromId, e.toId),
],
onNodeMoved: (id, pos) => _moveNode(combo, id, pos),
onNodeDragEnd: (_) => _service.persistNow(),
onConnect: (from, to) => _toggleEdge(combo, from, to),
onNodeLongPress: (id) => _deleteNode(combo, id),
),
),
),
],
),
),
],
);
}
static Color _accentFor(String technique) {
final t = technique.toLowerCase();
if (t.contains('chagi')) return AppColors.accent;
if (t.contains('jirugi') || t.contains('taerigi')) return AppColors.info;
if (t.contains('makgi')) return AppColors.purple;
return AppColors.zinc500;
}
}
// ---------------------------------------------------------------------------
// Left panel — combo list
// ---------------------------------------------------------------------------
class _ComboListPanel extends StatelessWidget {
const _ComboListPanel({
required this.combos,
required this.selectedId,
required this.onSelect,
required this.onCreate,
required this.onDelete,
});
final List<Combo> combos;
final String? selectedId;
final ValueChanged<String> onSelect;
final VoidCallback onCreate;
final ValueChanged<String> onDelete;
@override
Widget build(BuildContext context) {
return Container(
width: 230,
color: AppColors.surfaceContainer,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.all(UIConstants.spacing12),
child: OutlinedButton.icon(
onPressed: onCreate,
icon: const Icon(Icons.add, size: 15),
label: const Text('New Combo'),
),
),
Expanded(
child: ListView.builder(
itemCount: combos.length,
itemBuilder: (context, index) {
final combo = combos[index];
final selected = combo.id == selectedId;
return InkWell(
onTap: () => onSelect(combo.id),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing12,
vertical: UIConstants.spacing8,
),
color: selected
? AppColors.accentMuted
: Colors.transparent,
child: Row(
children: [
Icon(
Icons.account_tree_outlined,
size: 14,
color: selected
? AppColors.accent
: AppColors.textMuted,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
combo.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: selected
? FontWeight.w600
: FontWeight.w400,
color: selected
? AppColors.textPrimary
: AppColors.textSecondary,
),
),
Text(
'${combo.nodes.length} techniques · '
'${combo.edges.length} links',
style: GoogleFonts.jetBrainsMono(
fontSize: 9,
color: AppColors.textMuted,
),
),
],
),
),
IconButton(
onPressed: () => onDelete(combo.id),
icon: const Icon(Icons.delete_outline, size: 14),
color: AppColors.textMuted,
visualDensity: VisualDensity.compact,
tooltip: 'Delete combo',
),
],
),
),
);
},
),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Toolbar above the canvas
// ---------------------------------------------------------------------------
class _Toolbar extends StatelessWidget {
const _Toolbar({
required this.combo,
required this.onAddTechnique,
required this.onGenerate,
});
final Combo combo;
final VoidCallback onAddTechnique;
final VoidCallback? onGenerate;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing16,
vertical: UIConstants.spacing8,
),
decoration: const BoxDecoration(
color: AppColors.surfaceContainer,
border: Border(bottom: BorderSide(color: AppColors.border)),
),
child: Row(
children: [
OutlinedButton.icon(
onPressed: onAddTechnique,
icon: const Icon(Icons.add, size: 15),
label: const Text('Technique'),
),
const SizedBox(width: UIConstants.spacing8),
FilledButton.icon(
onPressed: onGenerate,
icon: const Icon(Icons.casino_outlined, size: 15),
label: const Text('GENERATE COMBO'),
),
const Spacer(),
Text(
'DRAG NODES · LINK WITH → · LONG-PRESS TO DELETE',
style: GoogleFonts.jetBrainsMono(
fontSize: 9,
letterSpacing: 0.8,
color: AppColors.textMuted,
),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Technique picker dialog
// ---------------------------------------------------------------------------
class _TechniquePickerDialog extends StatefulWidget {
const _TechniquePickerDialog();
@override
State<_TechniquePickerDialog> createState() => _TechniquePickerDialogState();
}
class _TechniquePickerDialogState extends State<_TechniquePickerDialog> {
final _customController = TextEditingController();
final _searchController = TextEditingController();
List<ExerciseEntity> _exercises = [];
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final exercises = await getIt<ExerciseRepository>().getAll();
if (mounted) {
setState(() {
_exercises = exercises;
_loading = false;
});
}
}
@override
void dispose() {
_customController.dispose();
_searchController.dispose();
super.dispose();
}
List<ExerciseEntity> get _filtered {
final query = _searchController.text.trim().toLowerCase();
if (query.isEmpty) return _exercises;
return _exercises
.where(
(e) =>
e.name.toLowerCase().contains(query) ||
(e.tags ?? '').toLowerCase().contains(query),
)
.toList();
}
@override
Widget build(BuildContext context) {
final filtered = _filtered;
return AlertDialog(
title: Text(
'ADD TECHNIQUE',
style: GoogleFonts.chakraPetch(
fontSize: 15,
fontWeight: FontWeight.w700,
letterSpacing: 1,
color: AppColors.textPrimary,
),
),
content: SizedBox(
width: 420,
height: 460,
child: Column(
children: [
TextField(
controller: _searchController,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
hintText: 'Search your exercise library…',
prefixIcon: Icon(Icons.search, size: 16),
),
style: GoogleFonts.inter(
fontSize: 13,
color: AppColors.textPrimary,
),
),
const SizedBox(height: UIConstants.spacing8),
Expanded(
child: _loading
? const Center(child: CircularProgressIndicator())
: filtered.isEmpty
? Center(
child: Text(
_exercises.isEmpty
? 'No exercises in your library yet.\n'
'Add them in the EXERCISES tab, or type '
'a custom technique below.'
: 'No match — try the custom field below.',
textAlign: TextAlign.center,
style: GoogleFonts.inter(
fontSize: 12,
color: AppColors.textMuted,
height: 1.6,
),
),
)
: ListView.builder(
itemCount: filtered.length,
itemBuilder: (context, index) {
final exercise = filtered[index];
return ListTile(
dense: true,
onTap: () => Navigator.pop(context, (
exercise.name,
exercise.tags ?? '',
)),
title: Text(
exercise.name,
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary,
),
),
subtitle: (exercise.tags ?? '').isEmpty
? null
: Text(
exercise.tags!,
style: GoogleFonts.inter(
fontSize: 11,
color: AppColors.textMuted,
),
),
);
},
),
),
const Divider(height: 1, color: AppColors.border),
Padding(
padding: const EdgeInsets.only(top: UIConstants.spacing12),
child: Row(
children: [
Expanded(
child: TextField(
controller: _customController,
decoration: const InputDecoration(
hintText: 'Custom technique…',
),
style: GoogleFonts.inter(
fontSize: 13,
color: AppColors.textPrimary,
),
onSubmitted: (v) {
if (v.trim().isNotEmpty) {
Navigator.pop(context, (v.trim(), ''));
}
},
),
),
const SizedBox(width: UIConstants.spacing8),
FilledButton(
onPressed: () {
final v = _customController.text.trim();
if (v.isNotEmpty) Navigator.pop(context, (v, ''));
},
child: const Text('Add'),
),
],
),
),
],
),
),
);
}
}
// ---------------------------------------------------------------------------
// Generated combo dialog
// ---------------------------------------------------------------------------
class _GeneratedComboDialog extends StatefulWidget {
const _GeneratedComboDialog({required this.combo});
final Combo combo;
@override
State<_GeneratedComboDialog> createState() => _GeneratedComboDialogState();
}
class _GeneratedComboDialogState extends State<_GeneratedComboDialog> {
late List<ComboNode> _sequence = widget.combo.generateSequence(length: 4);
bool _adding = false;
/// Appends the generated sequence to a chosen training plan as a new
/// section — one exercise per technique, editable later in the plan editor.
Future<void> _addToPlan() async {
final repo = getIt<TrainingPlanRepository>();
final plans = await repo.getAll();
if (!mounted) return;
if (plans.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No training plans yet — create one first.'),
),
);
return;
}
final plan = await showDialog<TrainingPlanEntity>(
context: context,
builder: (ctx) => SimpleDialog(
title: Text(
'ADD TO PLAN',
style: GoogleFonts.chakraPetch(
fontSize: 14,
fontWeight: FontWeight.w700,
letterSpacing: 1,
color: AppColors.textPrimary,
),
),
children: [
for (final p in plans)
SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, p),
child: Text(
p.name,
style: GoogleFonts.inter(
fontSize: 13,
color: AppColors.textPrimary,
),
),
),
],
),
);
if (plan == null || !mounted) return;
setState(() => _adding = true);
final section = TrainingSectionEntity(
id: _uuid.v4(),
name: 'Combo: ${widget.combo.name}',
exercises: [
for (final node in _sequence)
TrainingExerciseEntity(
instanceId: _uuid.v4(),
exerciseId: node.id,
name: node.technique,
sets: 2,
value: 10,
isTime: false,
rest: 30,
),
],
);
await repo.update(plan.copyWith(sections: [...plan.sections, section]));
if (!mounted) return;
final messenger = ScaffoldMessenger.of(context);
Navigator.pop(context);
messenger.showSnackBar(
SnackBar(content: Text('Combo added to "${plan.name}"')),
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(
'YOUR COMBO',
style: GoogleFonts.chakraPetch(
fontSize: 15,
fontWeight: FontWeight.w700,
letterSpacing: 1,
color: AppColors.accent,
),
),
content: SizedBox(
width: 420,
child: _sequence.isEmpty
? Text(
'Add techniques and link them first.',
style: GoogleFonts.inter(
fontSize: 13,
color: AppColors.textSecondary,
),
)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < _sequence.length; i++) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 10,
),
decoration: BoxDecoration(
color: AppColors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.border),
),
child: Row(
children: [
Text(
'${i + 1}',
style: GoogleFonts.jetBrainsMono(
fontSize: 12,
fontWeight: FontWeight.w700,
color: AppColors.accent,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
_sequence[i].technique.toUpperCase(),
style: GoogleFonts.chakraPetch(
fontSize: 13,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
color: AppColors.textPrimary,
),
),
),
],
),
),
if (i < _sequence.length - 1)
const Padding(
padding: EdgeInsets.symmetric(vertical: 2),
child: Icon(
Icons.arrow_downward_rounded,
size: 14,
color: AppColors.textMuted,
),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => setState(
() => _sequence = widget.combo.generateSequence(length: 4),
),
child: const Text('REROLL'),
),
OutlinedButton.icon(
onPressed: _sequence.isEmpty || _adding ? null : _addToPlan,
icon: const Icon(Icons.playlist_add_rounded, size: 15),
label: const Text('ADD TO PLAN'),
),
FilledButton(
onPressed: () => Navigator.pop(context),
child: const Text('DONE'),
),
],
);
}
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
class _EmptyState extends StatelessWidget {
const _EmptyState({required this.onCreate});
final VoidCallback onCreate;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.account_tree_outlined,
size: 48,
color: AppColors.textMuted,
),
const SizedBox(height: UIConstants.spacing16),
Text(
'COMBO BUILDER',
style: GoogleFonts.chakraPetch(
fontSize: 18,
fontWeight: FontWeight.w700,
letterSpacing: 1,
color: AppColors.textPrimary,
),
),
const SizedBox(height: UIConstants.spacing8),
Text(
'Map your techniques as a graph — nodes are techniques,\n'
'links are transitions that flow well. Then generate\n'
'random combos to drill on the timer.',
textAlign: TextAlign.center,
style: GoogleFonts.inter(
fontSize: 13,
color: AppColors.textSecondary,
height: 1.6,
),
),
const SizedBox(height: UIConstants.spacing24),
FilledButton.icon(
onPressed: onCreate,
icon: const Icon(Icons.add, size: 16),
label: const Text('CREATE FIRST COMBO'),
),
],
),
);
}
}

View File

@@ -28,12 +28,12 @@ class _WelcomeScreenState extends ConsumerState<WelcomeScreen> {
.read(aiModelSettingsControllerProvider.notifier)
.validateModels()
.then((_) {
if (!mounted) return;
final validated = ref
.read(aiModelSettingsControllerProvider)
.areModelsValidated;
if (validated) _navigateToApp();
});
if (!mounted) return;
final validated = ref
.read(aiModelSettingsControllerProvider)
.areModelsValidated;
if (validated) _navigateToApp();
});
});
}
@@ -47,11 +47,11 @@ class _WelcomeScreenState extends ConsumerState<WelcomeScreen> {
Widget build(BuildContext context) {
final modelState = ref.watch(aiModelSettingsControllerProvider);
ref.listen<AiModelSettingsState>(aiModelSettingsControllerProvider,
(prev, next) {
if (!_hasNavigated &&
next.areModelsValidated &&
!next.isDownloading) {
ref.listen<AiModelSettingsState>(aiModelSettingsControllerProvider, (
prev,
next,
) {
if (!_hasNavigated && next.areModelsValidated && !next.isDownloading) {
_navigateToApp();
}
});

View File

@@ -32,12 +32,12 @@ class DownloadProgress extends StatelessWidget {
),
const SizedBox(height: UIConstants.spacing24),
Text(
'Setting up AI models\u2026',
style: GoogleFonts.inter(
'SETTING UP AI MODELS\u2026',
style: GoogleFonts.chakraPetch(
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
letterSpacing: -0.3,
letterSpacing: 1,
),
),
const SizedBox(height: UIConstants.spacing8),
@@ -55,8 +55,7 @@ class DownloadProgress extends StatelessWidget {
value: modelState.progress,
minHeight: 6,
backgroundColor: AppColors.zinc800,
valueColor:
const AlwaysStoppedAnimation<Color>(AppColors.accent),
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.accent),
),
),
const SizedBox(height: UIConstants.spacing12),
@@ -105,9 +104,7 @@ class ErrorBanner extends StatelessWidget {
decoration: BoxDecoration(
color: AppColors.destructiveMuted,
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
border: Border.all(
color: AppColors.destructive.withValues(alpha: 0.4),
),
border: Border.all(color: AppColors.destructive.withValues(alpha: 0.4)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,

View File

@@ -46,19 +46,19 @@ class InitialPrompt extends StatelessWidget {
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.fitness_center,
Icons.sports_martial_arts,
color: AppColors.accent,
size: 24,
),
),
const SizedBox(width: UIConstants.spacing12),
Text(
'TrainHub',
style: GoogleFonts.inter(
'TRAINHUB',
style: GoogleFonts.chakraPetch(
fontSize: 26,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
letterSpacing: -0.5,
letterSpacing: 2,
),
),
],
@@ -67,13 +67,13 @@ class InitialPrompt extends StatelessWidget {
Widget _buildHeadline() {
return Text(
'AI-powered coaching,\nright on your device.',
style: GoogleFonts.inter(
'AI-POWERED COACHING,\nRIGHT ON YOUR DEVICE.',
style: GoogleFonts.chakraPetch(
fontSize: 28,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
height: 1.25,
letterSpacing: -0.5,
letterSpacing: 0.5,
),
);
}
@@ -101,7 +101,7 @@ class InitialPrompt extends StatelessWidget {
SizedBox(height: UIConstants.spacing12),
FeatureRow(
icon: Icons.psychology_outlined,
label: 'Qwen 2.5 7B chat model for training advice.',
label: 'Qwen3 4B chat model for training advice.',
),
SizedBox(height: UIConstants.spacing12),
FeatureRow(
@@ -121,9 +121,7 @@ class InitialPrompt extends StatelessWidget {
decoration: BoxDecoration(
color: AppColors.accentMuted,
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
border: Border.all(
color: AppColors.accent.withValues(alpha: 0.3),
),
border: Border.all(color: AppColors.accent.withValues(alpha: 0.3)),
),
child: Row(
children: [
@@ -135,7 +133,7 @@ class InitialPrompt extends StatelessWidget {
const SizedBox(width: UIConstants.spacing12),
Expanded(
child: Text(
'The download is ~5 GB and only needs to happen once. '
'The download is ~2.7 GB and only needs to happen once. '
'You can skip now and download later from Settings.',
style: GoogleFonts.inter(
fontSize: 13,

View File

@@ -39,8 +39,7 @@ class _WelcomePrimaryButtonState extends State<WelcomePrimaryButton> {
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius:
BorderRadius.circular(UIConstants.smallBorderRadius),
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
onTap: widget.onPressed,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -75,8 +74,7 @@ class WelcomeSecondaryButton extends StatefulWidget {
final VoidCallback onPressed;
@override
State<WelcomeSecondaryButton> createState() =>
_WelcomeSecondaryButtonState();
State<WelcomeSecondaryButton> createState() => _WelcomeSecondaryButtonState();
}
class _WelcomeSecondaryButtonState extends State<WelcomeSecondaryButton> {
@@ -98,8 +96,7 @@ class _WelcomeSecondaryButtonState extends State<WelcomeSecondaryButton> {
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius:
BorderRadius.circular(UIConstants.smallBorderRadius),
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
onTap: widget.onPressed,
child: Center(
child: Text(

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/domain/entities/workout_activity.dart';
@@ -21,9 +22,7 @@ class ActivityCard extends StatelessWidget {
decoration: BoxDecoration(
color: AppColors.surfaceContainer.withValues(alpha: 0.6),
borderRadius: UIConstants.cardBorderRadius,
border: Border.all(
color: accentColor.withValues(alpha: 0.2),
),
border: Border.all(color: accentColor.withValues(alpha: 0.2)),
),
child: Column(
children: [
@@ -47,11 +46,12 @@ class ActivityCard extends StatelessWidget {
const SizedBox(height: UIConstants.spacing12),
// Activity name
Text(
activity.name,
style: const TextStyle(
activity.name.toUpperCase(),
style: GoogleFonts.chakraPetch(
color: AppColors.textPrimary,
fontSize: 22,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
),
textAlign: TextAlign.center,
),
@@ -59,10 +59,7 @@ class ActivityCard extends StatelessWidget {
const SizedBox(height: UIConstants.spacing8),
Text(
'${activity.sectionName ?? ''} \u00B7 Set ${activity.setIndex}/${activity.totalSets}',
style: const TextStyle(
color: AppColors.textMuted,
fontSize: 13,
),
style: const TextStyle(color: AppColors.textMuted, fontSize: 13),
),
if (activity.originalExercise != null) ...[
const SizedBox(height: UIConstants.spacing16),
@@ -86,10 +83,7 @@ class ActivityCard extends StatelessWidget {
padding: const EdgeInsets.only(top: UIConstants.spacing8),
child: Text(
'Take a break',
style: TextStyle(
color: AppColors.textMuted,
fontSize: 14,
),
style: TextStyle(color: AppColors.textMuted, fontSize: 14),
),
),
],
@@ -124,10 +118,7 @@ class _InfoChip extends StatelessWidget {
),
Text(
label,
style: const TextStyle(
color: AppColors.textMuted,
fontSize: 11,
),
style: const TextStyle(color: AppColors.textMuted, fontSize: 11),
),
],
),

View File

@@ -46,11 +46,7 @@ class SessionControls extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
if (isTimeBased) ...[
_ControlButton(
icon: Icons.replay_10,
onTap: onRewind,
size: 24,
),
_ControlButton(icon: Icons.replay_10, onTap: onRewind, size: 24),
const SizedBox(width: UIConstants.spacing8),
],
_ControlButton(

View File

@@ -101,9 +101,7 @@ class WorkoutSessionController extends _$WorkoutSessionController {
0,
maxDuration,
);
state = AsyncValue.data(
currentState.copyWith(timeRemaining: newRemaining),
);
state = AsyncValue.data(currentState.copyWith(timeRemaining: newRemaining));
}
void _tick(Timer timer) {

View File

@@ -2,6 +2,7 @@ import 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/domain/entities/workout_activity.dart';
@@ -25,7 +26,7 @@ class WorkoutSessionPage extends ConsumerWidget {
final asyncState = ref.watch(workoutSessionControllerProvider(planId));
return Scaffold(
backgroundColor: AppColors.zinc950,
backgroundColor: AppColors.surface,
body: asyncState.when(
loading: () => const Center(
child: CircularProgressIndicator(
@@ -71,15 +72,10 @@ class WorkoutSessionPage extends ConsumerWidget {
);
if (state.isFinished) {
return _CompletionScreen(
totalTimeElapsed: state.totalTimeElapsed,
);
return _CompletionScreen(totalTimeElapsed: state.totalTimeElapsed);
}
return _ActiveSessionView(
state: state,
controller: controller,
);
return _ActiveSessionView(state: state, controller: controller);
},
),
);
@@ -90,10 +86,7 @@ class _ActiveSessionView extends StatefulWidget {
final WorkoutSessionState state;
final WorkoutSessionController controller;
const _ActiveSessionView({
required this.state,
required this.controller,
});
const _ActiveSessionView({required this.state, required this.controller});
@override
State<_ActiveSessionView> createState() => _ActiveSessionViewState();
@@ -110,8 +103,7 @@ class _ActiveSessionViewState extends State<_ActiveSessionView> {
final double timeProgress;
if (activity != null && activity.duration > 0) {
timeProgress =
1.0 - (widget.state.timeRemaining / activity.duration);
timeProgress = 1.0 - (widget.state.timeRemaining / activity.duration);
} else {
timeProgress = 0.0;
}
@@ -128,11 +120,7 @@ class _ActiveSessionViewState extends State<_ActiveSessionView> {
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.zinc950,
accentTint,
AppColors.zinc950,
],
colors: [AppColors.surface, accentTint, AppColors.surface],
stops: const [0.0, 0.5, 1.0],
),
),
@@ -149,8 +137,7 @@ class _ActiveSessionViewState extends State<_ActiveSessionView> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (activity != null)
ActivityCard(activity: activity),
if (activity != null) ActivityCard(activity: activity),
const SizedBox(height: UIConstants.spacing32),
if (isTimeBased)
_CircularTimerDisplay(
@@ -249,26 +236,20 @@ class _ActiveSessionViewState extends State<_ActiveSessionView> {
decoration: BoxDecoration(
color: AppColors.zinc800.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.border.withValues(alpha: 0.5),
),
border: Border.all(color: AppColors.border.withValues(alpha: 0.5)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.timer_outlined,
size: 14,
color: AppColors.textMuted,
),
Icon(Icons.timer_outlined, size: 14, color: AppColors.textMuted),
const SizedBox(width: 4),
Text(
_formatDuration(widget.state.totalTimeElapsed),
style: const TextStyle(
style: GoogleFonts.jetBrainsMono(
color: AppColors.textSecondary,
fontSize: 13,
fontWeight: FontWeight.w500,
fontFeatures: [FontFeature.tabularFigures()],
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
@@ -352,11 +333,10 @@ class _RepsDisplay extends StatelessWidget {
children: [
Text(
'$reps',
style: TextStyle(
style: GoogleFonts.jetBrainsMono(
color: AppColors.textPrimary,
fontSize: 72,
fontWeight: FontWeight.w200,
letterSpacing: -2,
fontWeight: FontWeight.w700,
shadows: [
Shadow(
color: AppColors.accent.withValues(alpha: 0.3),
@@ -462,13 +442,11 @@ class _CircularTimerDisplay extends StatelessWidget {
children: [
Text(
_formatTime(timeRemaining),
style: TextStyle(
style: GoogleFonts.jetBrainsMono(
color: AppColors.textPrimary,
fontSize: 64,
fontWeight: FontWeight.w200,
letterSpacing: -1,
fontWeight: FontWeight.w700,
fontFeatures: const [FontFeature.tabularFigures()],
fontFamily: 'monospace',
shadows: [
Shadow(
color: ringColor.withValues(alpha: 0.3),
@@ -525,10 +503,7 @@ class TimerRingPainter extends CustomPainter {
final double progress;
final Color ringColor;
TimerRingPainter({
required this.progress,
required this.ringColor,
});
TimerRingPainter({required this.progress, required this.ringColor});
@override
void paint(Canvas canvas, Size size) {
@@ -551,10 +526,10 @@ class TimerRingPainter extends CustomPainter {
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth * 2
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 16);
final sweepAngle = 2 * math.pi * progress;
final startAngle = -math.pi / 2;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
startAngle,
@@ -576,7 +551,9 @@ class TimerRingPainter extends CustomPainter {
);
final progressPaint = Paint()
..shader = gradient.createShader(Rect.fromCircle(center: center, radius: radius))
..shader = gradient.createShader(
Rect.fromCircle(center: center, radius: radius),
)
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeWidth = strokeWidth;
@@ -593,7 +570,7 @@ class TimerRingPainter extends CustomPainter {
@override
bool shouldRepaint(TimerRingPainter oldDelegate) {
return oldDelegate.progress != progress ||
oldDelegate.ringColor != ringColor;
oldDelegate.ringColor != ringColor;
}
}
@@ -601,10 +578,7 @@ class _UpNextPill extends StatelessWidget {
final String nextActivityName;
final bool isNextRest;
const _UpNextPill({
required this.nextActivityName,
required this.isNextRest,
});
const _UpNextPill({required this.nextActivityName, required this.isNextRest});
@override
Widget build(BuildContext context) {
@@ -693,9 +667,7 @@ class _ActivitiesListPanel extends StatelessWidget {
width: 320,
decoration: const BoxDecoration(
color: AppColors.surfaceContainer,
border: Border(
left: BorderSide(color: AppColors.border),
),
border: Border(left: BorderSide(color: AppColors.border)),
),
child: Column(
children: [
@@ -763,8 +735,8 @@ class _ActivitiesListPanel extends StatelessWidget {
color: isCurrent
? AppColors.accent
: isRest
? AppColors.info
: AppColors.zinc600,
? AppColors.info
: AppColors.zinc600,
),
),
const SizedBox(width: UIConstants.spacing12),
@@ -785,7 +757,8 @@ class _ActivitiesListPanel extends StatelessWidget {
: FontWeight.normal,
),
),
if (!isRest && activity.setIndex != null)
if (!isRest &&
activity.setIndex != null)
Text(
'Set ${activity.setIndex}/${activity.totalSets} · ${activity.sectionName ?? ''}',
style: const TextStyle(
@@ -888,22 +861,19 @@ class _CompletionScreen extends StatelessWidget {
),
),
const SizedBox(height: UIConstants.spacing24),
const Text(
'Workout Complete',
style: TextStyle(
Text(
'WORKOUT COMPLETE',
style: GoogleFonts.chakraPetch(
color: AppColors.textPrimary,
fontSize: 28,
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
fontWeight: FontWeight.w700,
letterSpacing: 1,
),
),
const SizedBox(height: UIConstants.spacing8),
Text(
'Great job! You crushed it.',
style: TextStyle(
color: AppColors.textSecondary,
fontSize: 15,
),
style: TextStyle(color: AppColors.textSecondary, fontSize: 15),
),
const SizedBox(height: UIConstants.spacing32),
Container(
@@ -932,12 +902,11 @@ class _CompletionScreen extends StatelessWidget {
const SizedBox(height: UIConstants.spacing4),
Text(
_formatDuration(totalTimeElapsed),
style: TextStyle(
style: GoogleFonts.jetBrainsMono(
color: AppColors.textPrimary,
fontSize: 36,
fontWeight: FontWeight.w300,
fontWeight: FontWeight.w600,
fontFeatures: const [FontFeature.tabularFigures()],
fontFamily: 'monospace',
),
),
],

View File

@@ -21,8 +21,8 @@ class WorkoutSessionState with _$WorkoutSessionState {
WorkoutActivityEntity? get nextActivity =>
currentIndex + 1 < activities.length
? activities[currentIndex + 1]
: null;
? activities[currentIndex + 1]
: null;
double get progress =>
activities.isEmpty ? 0.0 : currentIndex / activities.length;