Przebudowanie aplikacji, usprawnione AI, dodanie combo buildera
Some checks failed
Build Linux App / build (push) Failing after 1m18s
Some checks failed
Build Linux App / build (push) Failing after 1m18s
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -44,3 +44,4 @@ app.*.map.json
|
|||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
/dist/
|
||||||
|
|||||||
@@ -12,9 +12,12 @@ class AiConstants {
|
|||||||
'http://localhost:$chatServerPort/v1/chat/completions';
|
'http://localhost:$chatServerPort/v1/chat/completions';
|
||||||
static String get embeddingApiUrl =>
|
static String get embeddingApiUrl =>
|
||||||
'http://localhost:$embeddingServerPort/v1/embeddings';
|
'http://localhost:$embeddingServerPort/v1/embeddings';
|
||||||
|
static String chatHealthUrl = 'http://localhost:$chatServerPort/health';
|
||||||
|
static String embeddingHealthUrl =
|
||||||
|
'http://localhost:$embeddingServerPort/health';
|
||||||
|
|
||||||
// Model files
|
// Model files
|
||||||
static const String qwenModelFile = 'qwen2.5-7b-instruct-q4_k_m.gguf';
|
static const String qwenModelFile = 'qwen3-4b-instruct-2507-q4_k_m.gguf';
|
||||||
static const String nomicModelFile = 'nomic-embed-text-v1.5.Q4_K_M.gguf';
|
static const String nomicModelFile = 'nomic-embed-text-v1.5.Q4_K_M.gguf';
|
||||||
static const String nomicModelName = 'nomic-embed-text-v1.5.Q4_K_M';
|
static const String nomicModelName = 'nomic-embed-text-v1.5.Q4_K_M';
|
||||||
|
|
||||||
@@ -27,13 +30,30 @@ class AiConstants {
|
|||||||
static const String nomicModelUrl =
|
static const String nomicModelUrl =
|
||||||
'https://huggingface.co/nomic-ai/nomic-embed-text-v1.5-GGUF/resolve/main/nomic-embed-text-v1.5.Q4_K_M.gguf';
|
'https://huggingface.co/nomic-ai/nomic-embed-text-v1.5-GGUF/resolve/main/nomic-embed-text-v1.5.Q4_K_M.gguf';
|
||||||
static const String qwenModelUrl =
|
static const String qwenModelUrl =
|
||||||
'https://huggingface.co/bartowski/Qwen2.5-7B-Instruct-GGUF/resolve/main/Qwen2.5-7B-Instruct-Q4_K_M.gguf';
|
'https://huggingface.co/bartowski/Qwen_Qwen3-4B-Instruct-2507-GGUF/resolve/main/Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf';
|
||||||
|
|
||||||
|
// Human-readable model labels (single source of truth for UI copy)
|
||||||
|
static const String chatModelLabel = 'Qwen3 4B Instruct Q4_K_M';
|
||||||
|
static const String embeddingModelLabel = 'Nomic Embed Text v1.5 Q4_K_M';
|
||||||
|
|
||||||
|
// Minimum plausible file sizes — a partially downloaded file is smaller.
|
||||||
|
static const int qwenModelMinBytes = 2_000_000_000; // full file ≈ 2.4 GB
|
||||||
|
static const int nomicModelMinBytes = 50_000_000; // full file ≈ 84 MB
|
||||||
|
static const int serverBinaryMinBytes = 1_000_000;
|
||||||
|
|
||||||
// Server configuration
|
// Server configuration
|
||||||
static const int qwenContextSize = 4096;
|
static const int qwenContextSize = 8192;
|
||||||
static const int nomicContextSize = 8192;
|
static const int nomicContextSize = 2048;
|
||||||
static const int gpuLayerOffload = 99;
|
static const int gpuLayerOffload = 99;
|
||||||
static const double chatTemperature = 0.7;
|
static const double chatTemperature = 0.7;
|
||||||
|
static const int chatMaxTokens = 1536;
|
||||||
|
|
||||||
|
/// How many most-recent messages are sent back to the model as history.
|
||||||
|
static const int chatHistoryLimit = 12;
|
||||||
|
|
||||||
|
/// Nomic v1.5 requires task prefixes for good retrieval quality.
|
||||||
|
static const String nomicDocumentPrefix = 'search_document: ';
|
||||||
|
static const String nomicQueryPrefix = 'search_query: ';
|
||||||
|
|
||||||
// Timeouts
|
// Timeouts
|
||||||
static const Duration serverConnectTimeout = Duration(seconds: 30);
|
static const Duration serverConnectTimeout = Duration(seconds: 30);
|
||||||
@@ -41,9 +61,21 @@ class AiConstants {
|
|||||||
static const Duration embeddingConnectTimeout = Duration(seconds: 10);
|
static const Duration embeddingConnectTimeout = Duration(seconds: 10);
|
||||||
static const Duration embeddingReceiveTimeout = Duration(seconds: 60);
|
static const Duration embeddingReceiveTimeout = Duration(seconds: 60);
|
||||||
|
|
||||||
|
/// Cold-loading a multi-GB model can take minutes: on the first Vulkan
|
||||||
|
/// run llama.cpp additionally compiles GPU pipelines for the device.
|
||||||
|
static const Duration serverStartupTimeout = Duration(minutes: 5);
|
||||||
|
|
||||||
|
/// CPU fallback loads via mmap and needs no pipeline compilation.
|
||||||
|
static const Duration serverStartupTimeoutCpu = Duration(minutes: 3);
|
||||||
|
static const Duration healthPollInterval = Duration(milliseconds: 750);
|
||||||
|
|
||||||
// System prompt
|
// System prompt
|
||||||
static const String baseSystemPrompt =
|
static const String baseSystemPrompt =
|
||||||
'You are a helpful AI fitness assistant for personal trainers. '
|
'You are the AI coach inside TrainHub, a training hub for ITF Taekwon-Do. '
|
||||||
'Help users design training plans, analyse exercise technique, '
|
'You help with: designing drills and training plans (patterns/tul, '
|
||||||
'and answer questions about sports science and nutrition.';
|
'sparring/matsogi, kicks, conditioning), analysing technique, belt '
|
||||||
|
'grading preparation, and sports science questions. '
|
||||||
|
'Use correct ITF terminology (e.g. dollyo chagi, yop chagi, tul names). '
|
||||||
|
'Always answer in the same language the user writes in. '
|
||||||
|
'Be concise and practical — coaches read this between rounds.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ class AppConstants {
|
|||||||
AppConstants._();
|
AppConstants._();
|
||||||
|
|
||||||
static const String appName = 'TrainHub';
|
static const String appName = 'TrainHub';
|
||||||
static const String appVersion = '2.0.0';
|
static const String appVersion = '2.1.0';
|
||||||
static const double windowWidth = 1280;
|
static const double windowWidth = 1280;
|
||||||
static const double windowHeight = 800;
|
static const double windowHeight = 800;
|
||||||
static const double minWindowWidth = 800;
|
static const double minWindowWidth = 800;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||||
|
|
||||||
class UIConstants {
|
class UIConstants {
|
||||||
UIConstants._();
|
UIConstants._();
|
||||||
@@ -29,4 +30,9 @@ class UIConstants {
|
|||||||
|
|
||||||
static BorderRadius get smallCardBorderRadius =>
|
static BorderRadius get smallCardBorderRadius =>
|
||||||
BorderRadius.circular(smallBorderRadius);
|
BorderRadius.circular(smallBorderRadius);
|
||||||
|
|
||||||
|
/// Soft red glow behind primary accents (CTA buttons, active timers).
|
||||||
|
static List<BoxShadow> get accentGlow => const [
|
||||||
|
BoxShadow(color: AppColors.accentGlow, blurRadius: 24, spreadRadius: 1),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,19 +16,22 @@ class AppColors {
|
|||||||
static const Color zinc900 = Color(0xFF18181B);
|
static const Color zinc900 = Color(0xFF18181B);
|
||||||
static const Color zinc950 = Color(0xFF09090B);
|
static const Color zinc950 = Color(0xFF09090B);
|
||||||
|
|
||||||
// Semantic colors
|
// Semantic colors — "dojang" dark: near-black surfaces, subtle borders
|
||||||
static const Color surface = zinc950;
|
static const Color surface = Color(0xFF070708);
|
||||||
static const Color surfaceContainer = zinc900;
|
static const Color surfaceContainer = Color(0xFF111113);
|
||||||
static const Color surfaceContainerHigh = zinc800;
|
static const Color surfaceContainerHigh = Color(0xFF1A1A1D);
|
||||||
static const Color border = zinc800;
|
static const Color border = Color(0xFF232326);
|
||||||
static const Color borderSubtle = Color(0xFF1F1F23);
|
static const Color borderSubtle = Color(0xFF1A1A1E);
|
||||||
static const Color textPrimary = zinc50;
|
static const Color textPrimary = zinc50;
|
||||||
static const Color textSecondary = zinc400;
|
static const Color textSecondary = zinc400;
|
||||||
static const Color textMuted = zinc500;
|
static const Color textMuted = zinc500;
|
||||||
|
|
||||||
// Accent colors
|
// Accent colors — signature red
|
||||||
static const Color accent = Color(0xFFFF9800);
|
static const Color accent = Color(0xFFFF2B2B);
|
||||||
static const Color accentMuted = Color(0x33FF9800);
|
static const Color accentBright = Color(0xFFFF4D4D);
|
||||||
|
static const Color accentMuted = Color(0x2EFF2B2B);
|
||||||
|
static const Color accentBorder = Color(0x40FF2B2B);
|
||||||
|
static const Color accentGlow = Color(0x59FF2B2B);
|
||||||
static const Color success = Color(0xFF22C55E);
|
static const Color success = Color(0xFF22C55E);
|
||||||
static const Color successMuted = Color(0x3322C55E);
|
static const Color successMuted = Color(0x3322C55E);
|
||||||
static const Color destructive = Color(0xFFEF4444);
|
static const Color destructive = Color(0xFFEF4444);
|
||||||
|
|||||||
@@ -10,23 +10,32 @@ class AppTheme {
|
|||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
colorScheme: const ColorScheme.dark(
|
colorScheme: const ColorScheme.dark(
|
||||||
primary: AppColors.zinc50,
|
primary: AppColors.accent,
|
||||||
onPrimary: AppColors.zinc950,
|
onPrimary: AppColors.zinc950,
|
||||||
secondary: AppColors.zinc200,
|
secondary: AppColors.zinc200,
|
||||||
onSecondary: AppColors.zinc950,
|
onSecondary: AppColors.zinc950,
|
||||||
surface: AppColors.zinc950,
|
surface: AppColors.surface,
|
||||||
onSurface: AppColors.zinc50,
|
onSurface: AppColors.zinc50,
|
||||||
surfaceContainer: AppColors.zinc900,
|
surfaceContainer: AppColors.surfaceContainer,
|
||||||
error: AppColors.destructive,
|
error: AppColors.destructive,
|
||||||
onError: AppColors.zinc50,
|
onError: AppColors.zinc50,
|
||||||
outline: AppColors.zinc800,
|
outline: AppColors.border,
|
||||||
outlineVariant: AppColors.zinc700,
|
outlineVariant: AppColors.zinc700,
|
||||||
),
|
),
|
||||||
scaffoldBackgroundColor: AppColors.surface,
|
scaffoldBackgroundColor: AppColors.surface,
|
||||||
textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme).apply(
|
textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme)
|
||||||
bodyColor: AppColors.textPrimary,
|
.copyWith(
|
||||||
displayColor: AppColors.textPrimary,
|
displayLarge: GoogleFonts.chakraPetch(fontWeight: FontWeight.w700),
|
||||||
),
|
displayMedium: GoogleFonts.chakraPetch(fontWeight: FontWeight.w700),
|
||||||
|
displaySmall: GoogleFonts.chakraPetch(fontWeight: FontWeight.w700),
|
||||||
|
headlineLarge: GoogleFonts.chakraPetch(fontWeight: FontWeight.w700),
|
||||||
|
headlineMedium: GoogleFonts.chakraPetch(fontWeight: FontWeight.w600),
|
||||||
|
headlineSmall: GoogleFonts.chakraPetch(fontWeight: FontWeight.w600),
|
||||||
|
)
|
||||||
|
.apply(
|
||||||
|
bodyColor: AppColors.textPrimary,
|
||||||
|
displayColor: AppColors.textPrimary,
|
||||||
|
),
|
||||||
cardTheme: CardThemeData(
|
cardTheme: CardThemeData(
|
||||||
color: AppColors.surfaceContainer,
|
color: AppColors.surfaceContainer,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
@@ -41,13 +50,10 @@ class AppTheme {
|
|||||||
thickness: 1,
|
thickness: 1,
|
||||||
space: 1,
|
space: 1,
|
||||||
),
|
),
|
||||||
iconTheme: const IconThemeData(
|
iconTheme: const IconThemeData(color: AppColors.textSecondary, size: 20),
|
||||||
color: AppColors.textSecondary,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
navigationRailTheme: NavigationRailThemeData(
|
navigationRailTheme: NavigationRailThemeData(
|
||||||
backgroundColor: AppColors.surfaceContainer,
|
backgroundColor: AppColors.surfaceContainer,
|
||||||
selectedIconTheme: const IconThemeData(color: AppColors.textPrimary),
|
selectedIconTheme: const IconThemeData(color: AppColors.accent),
|
||||||
unselectedIconTheme: const IconThemeData(color: AppColors.textMuted),
|
unselectedIconTheme: const IconThemeData(color: AppColors.textMuted),
|
||||||
selectedLabelTextStyle: GoogleFonts.inter(
|
selectedLabelTextStyle: GoogleFonts.inter(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -59,7 +65,7 @@ class AppTheme {
|
|||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
color: AppColors.textMuted,
|
color: AppColors.textMuted,
|
||||||
),
|
),
|
||||||
indicatorColor: AppColors.zinc700,
|
indicatorColor: AppColors.accentMuted,
|
||||||
),
|
),
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
@@ -75,12 +81,9 @@ class AppTheme {
|
|||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: UIConstants.smallCardBorderRadius,
|
borderRadius: UIConstants.smallCardBorderRadius,
|
||||||
borderSide: const BorderSide(color: AppColors.zinc400, width: 1),
|
borderSide: const BorderSide(color: AppColors.accent, width: 1),
|
||||||
),
|
|
||||||
hintStyle: GoogleFonts.inter(
|
|
||||||
color: AppColors.textMuted,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
),
|
||||||
|
hintStyle: GoogleFonts.inter(color: AppColors.textMuted, fontSize: 14),
|
||||||
labelStyle: GoogleFonts.inter(
|
labelStyle: GoogleFonts.inter(
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
@@ -100,15 +103,16 @@ class AppTheme {
|
|||||||
),
|
),
|
||||||
filledButtonTheme: FilledButtonThemeData(
|
filledButtonTheme: FilledButtonThemeData(
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: AppColors.zinc50,
|
backgroundColor: AppColors.accent,
|
||||||
foregroundColor: AppColors.zinc950,
|
foregroundColor: AppColors.zinc950,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: UIConstants.smallCardBorderRadius,
|
borderRadius: UIConstants.smallCardBorderRadius,
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
textStyle: GoogleFonts.inter(
|
textStyle: GoogleFonts.chakraPetch(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.8,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -120,10 +124,7 @@ class AppTheme {
|
|||||||
borderRadius: UIConstants.smallCardBorderRadius,
|
borderRadius: UIConstants.smallCardBorderRadius,
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
textStyle: GoogleFonts.inter(
|
textStyle: GoogleFonts.inter(fontSize: 14, fontWeight: FontWeight.w500),
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
textButtonTheme: TextButtonThemeData(
|
textButtonTheme: TextButtonThemeData(
|
||||||
@@ -133,24 +134,23 @@ class AppTheme {
|
|||||||
borderRadius: UIConstants.smallCardBorderRadius,
|
borderRadius: UIConstants.smallCardBorderRadius,
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
textStyle: GoogleFonts.inter(
|
textStyle: GoogleFonts.inter(fontSize: 14, fontWeight: FontWeight.w500),
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
tabBarTheme: TabBarThemeData(
|
tabBarTheme: TabBarThemeData(
|
||||||
labelColor: AppColors.textPrimary,
|
labelColor: AppColors.textPrimary,
|
||||||
unselectedLabelColor: AppColors.textMuted,
|
unselectedLabelColor: AppColors.textMuted,
|
||||||
indicatorColor: AppColors.textPrimary,
|
indicatorColor: AppColors.accent,
|
||||||
dividerColor: AppColors.border,
|
dividerColor: AppColors.border,
|
||||||
labelStyle: GoogleFonts.inter(
|
labelStyle: GoogleFonts.chakraPetch(
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 1,
|
||||||
),
|
),
|
||||||
unselectedLabelStyle: GoogleFonts.inter(
|
unselectedLabelStyle: GoogleFonts.chakraPetch(
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w500,
|
||||||
|
letterSpacing: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
snackBarTheme: SnackBarThemeData(
|
snackBarTheme: SnackBarThemeData(
|
||||||
@@ -184,15 +184,13 @@ class AppTheme {
|
|||||||
checkboxTheme: CheckboxThemeData(
|
checkboxTheme: CheckboxThemeData(
|
||||||
fillColor: WidgetStateProperty.resolveWith((states) {
|
fillColor: WidgetStateProperty.resolveWith((states) {
|
||||||
if (states.contains(WidgetState.selected)) {
|
if (states.contains(WidgetState.selected)) {
|
||||||
return AppColors.zinc50;
|
return AppColors.accent;
|
||||||
}
|
}
|
||||||
return Colors.transparent;
|
return Colors.transparent;
|
||||||
}),
|
}),
|
||||||
checkColor: WidgetStateProperty.all(AppColors.zinc950),
|
checkColor: WidgetStateProperty.all(AppColors.zinc950),
|
||||||
side: const BorderSide(color: AppColors.zinc400),
|
side: const BorderSide(color: AppColors.zinc400),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
tooltipTheme: TooltipThemeData(
|
tooltipTheme: TooltipThemeData(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -200,10 +198,7 @@ class AppTheme {
|
|||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
border: Border.all(color: AppColors.zinc700),
|
border: Border.all(color: AppColors.zinc700),
|
||||||
),
|
),
|
||||||
textStyle: GoogleFonts.inter(
|
textStyle: GoogleFonts.inter(color: AppColors.textPrimary, fontSize: 12),
|
||||||
color: AppColors.textPrimary,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,99 +5,114 @@ import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
|||||||
class AppTypography {
|
class AppTypography {
|
||||||
AppTypography._();
|
AppTypography._();
|
||||||
|
|
||||||
static TextStyle get displayLarge => GoogleFonts.inter(
|
/// Display font for headings — squarish techno look ("dojang" style).
|
||||||
fontSize: 36,
|
/// Pair with `.toUpperCase()` on the text for the full effect.
|
||||||
fontWeight: FontWeight.w700,
|
static TextStyle get displayLarge => GoogleFonts.chakraPetch(
|
||||||
color: AppColors.textPrimary,
|
fontSize: 36,
|
||||||
height: 1.2,
|
fontWeight: FontWeight.w700,
|
||||||
);
|
color: AppColors.textPrimary,
|
||||||
|
height: 1.2,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
);
|
||||||
|
|
||||||
static TextStyle get headlineLarge => GoogleFonts.inter(
|
static TextStyle get headlineLarge => GoogleFonts.chakraPetch(
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
height: 1.3,
|
height: 1.3,
|
||||||
);
|
letterSpacing: 0.5,
|
||||||
|
);
|
||||||
|
|
||||||
static TextStyle get headlineMedium => GoogleFonts.inter(
|
static TextStyle get headlineMedium => GoogleFonts.chakraPetch(
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
height: 1.3,
|
height: 1.3,
|
||||||
);
|
letterSpacing: 0.5,
|
||||||
|
);
|
||||||
|
|
||||||
static TextStyle get titleLarge => GoogleFonts.inter(
|
static TextStyle get titleLarge => GoogleFonts.chakraPetch(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
height: 1.4,
|
height: 1.4,
|
||||||
);
|
letterSpacing: 0.3,
|
||||||
|
);
|
||||||
|
|
||||||
static TextStyle get titleMedium => GoogleFonts.inter(
|
static TextStyle get titleMedium => GoogleFonts.inter(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
height: 1.4,
|
height: 1.4,
|
||||||
);
|
);
|
||||||
|
|
||||||
static TextStyle get titleSmall => GoogleFonts.inter(
|
static TextStyle get titleSmall => GoogleFonts.inter(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
height: 1.4,
|
height: 1.4,
|
||||||
);
|
);
|
||||||
|
|
||||||
static TextStyle get bodyLarge => GoogleFonts.inter(
|
static TextStyle get bodyLarge => GoogleFonts.inter(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
);
|
);
|
||||||
|
|
||||||
static TextStyle get bodyMedium => GoogleFonts.inter(
|
static TextStyle get bodyMedium => GoogleFonts.inter(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
);
|
);
|
||||||
|
|
||||||
static TextStyle get bodySmall => GoogleFonts.inter(
|
static TextStyle get bodySmall => GoogleFonts.inter(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
);
|
);
|
||||||
|
|
||||||
static TextStyle get labelLarge => GoogleFonts.inter(
|
static TextStyle get labelLarge => GoogleFonts.inter(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
height: 1.4,
|
height: 1.4,
|
||||||
);
|
);
|
||||||
|
|
||||||
static TextStyle get labelMedium => GoogleFonts.inter(
|
static TextStyle get labelMedium => GoogleFonts.inter(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
height: 1.4,
|
height: 1.4,
|
||||||
);
|
);
|
||||||
|
|
||||||
static TextStyle get caption => GoogleFonts.inter(
|
static TextStyle get caption => GoogleFonts.inter(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
color: AppColors.textMuted,
|
color: AppColors.textMuted,
|
||||||
height: 1.4,
|
height: 1.4,
|
||||||
);
|
);
|
||||||
|
|
||||||
static TextStyle get monoLarge => GoogleFonts.jetBrainsMono(
|
static TextStyle get monoLarge => GoogleFonts.jetBrainsMono(
|
||||||
fontSize: 72,
|
fontSize: 72,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
);
|
);
|
||||||
|
|
||||||
static TextStyle get monoMedium => GoogleFonts.jetBrainsMono(
|
static TextStyle get monoMedium => GoogleFonts.jetBrainsMono(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Small tracked-out mono label, e.g. "TODAY · THU 29 MAY".
|
||||||
|
/// Callers uppercase the text and override the color (often [AppColors.accent]).
|
||||||
|
static TextStyle get overline => GoogleFonts.jetBrainsMono(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
letterSpacing: 2,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:convert';
|
|||||||
import 'dart:math' show sqrt;
|
import 'dart:math' show sqrt;
|
||||||
|
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:trainhub_flutter/core/constants/ai_constants.dart';
|
||||||
import 'package:trainhub_flutter/data/database/app_database.dart';
|
import 'package:trainhub_flutter/data/database/app_database.dart';
|
||||||
import 'package:trainhub_flutter/data/database/daos/knowledge_chunk_dao.dart';
|
import 'package:trainhub_flutter/data/database/daos/knowledge_chunk_dao.dart';
|
||||||
import 'package:trainhub_flutter/domain/repositories/note_repository.dart';
|
import 'package:trainhub_flutter/domain/repositories/note_repository.dart';
|
||||||
@@ -29,7 +30,11 @@ class NoteRepositoryImpl implements NoteRepository {
|
|||||||
final now = DateTime.now().toIso8601String();
|
final now = DateTime.now().toIso8601String();
|
||||||
|
|
||||||
for (final chunk in chunks) {
|
for (final chunk in chunks) {
|
||||||
final embedding = await _embeddingService.embed(chunk);
|
// Nomic v1.5 is trained with task prefixes — embedding without them
|
||||||
|
// noticeably degrades retrieval quality.
|
||||||
|
final embedding = await _embeddingService.embed(
|
||||||
|
'${AiConstants.nomicDocumentPrefix}$chunk',
|
||||||
|
);
|
||||||
await _dao.insertChunk(
|
await _dao.insertChunk(
|
||||||
KnowledgeChunksCompanion(
|
KnowledgeChunksCompanion(
|
||||||
id: Value(_uuid.v4()),
|
id: Value(_uuid.v4()),
|
||||||
@@ -47,19 +52,19 @@ class NoteRepositoryImpl implements NoteRepository {
|
|||||||
final allRows = await _dao.getAllChunks();
|
final allRows = await _dao.getAllChunks();
|
||||||
if (allRows.isEmpty) return [];
|
if (allRows.isEmpty) return [];
|
||||||
|
|
||||||
final queryEmbedding = await _embeddingService.embed(query);
|
final queryEmbedding = await _embeddingService.embed(
|
||||||
|
'${AiConstants.nomicQueryPrefix}$query',
|
||||||
|
);
|
||||||
|
|
||||||
final scored = allRows.map((row) {
|
final scored = allRows.map((row) {
|
||||||
final emb =
|
final emb = (jsonDecode(row.embedding) as List<dynamic>)
|
||||||
(jsonDecode(row.embedding) as List<dynamic>)
|
.map((e) => (e as num).toDouble())
|
||||||
.map((e) => (e as num).toDouble())
|
.toList();
|
||||||
.toList();
|
|
||||||
return _Scored(
|
return _Scored(
|
||||||
score: _cosineSimilarity(queryEmbedding, emb),
|
score: _cosineSimilarity(queryEmbedding, emb),
|
||||||
text: row.content,
|
text: row.content,
|
||||||
);
|
);
|
||||||
}).toList()
|
}).toList()..sort((a, b) => b.score.compareTo(a.score));
|
||||||
..sort((a, b) => b.score.compareTo(a.score));
|
|
||||||
|
|
||||||
return scored.take(topK).map((s) => s.text).toList();
|
return scored.take(topK).map((s) => s.text).toList();
|
||||||
}
|
}
|
||||||
@@ -92,13 +97,11 @@ class NoteRepositoryImpl implements NoteRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Split long paragraph by sentence boundaries (. ! ?)
|
// Split long paragraph by sentence boundaries (. ! ?)
|
||||||
final sentences =
|
final sentences = p.split(RegExp(r'(?<=[.!?])\s+'));
|
||||||
p.split(RegExp(r'(?<=[.!?])\s+'));
|
|
||||||
var current = '';
|
var current = '';
|
||||||
|
|
||||||
for (final sentence in sentences) {
|
for (final sentence in sentences) {
|
||||||
final candidate =
|
final candidate = current.isEmpty ? sentence : '$current $sentence';
|
||||||
current.isEmpty ? sentence : '$current $sentence';
|
|
||||||
if (candidate.length <= maxChars) {
|
if (candidate.length <= maxChars) {
|
||||||
current = candidate;
|
current = candidate;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:trainhub_flutter/core/constants/ai_constants.dart';
|
import 'package:trainhub_flutter/core/constants/ai_constants.dart';
|
||||||
@@ -12,13 +14,26 @@ enum AiServerStatus { offline, starting, ready, error }
|
|||||||
/// Both processes are kept alive for the lifetime of the app and must be
|
/// Both processes are kept alive for the lifetime of the app and must be
|
||||||
/// killed on shutdown to prevent zombie processes from consuming RAM.
|
/// killed on shutdown to prevent zombie processes from consuming RAM.
|
||||||
///
|
///
|
||||||
/// - Qwen 2.5 7B → port 8080 (chat / completions)
|
/// - Qwen3 4B → port 8080 (chat / completions)
|
||||||
/// - Nomic Embed → port 8081 (embeddings)
|
/// - Nomic Embed → port 8081 (embeddings)
|
||||||
|
///
|
||||||
|
/// Startup strategy: first attempt offloads all layers to the GPU (Vulkan).
|
||||||
|
/// If the chat server does not become healthy in time — first-run pipeline
|
||||||
|
/// compilation can be very slow, and some GPUs hang outright — everything is
|
||||||
|
/// restarted in CPU-only mode, which loads via mmap and is reliably fast.
|
||||||
class AiProcessManager extends ChangeNotifier {
|
class AiProcessManager extends ChangeNotifier {
|
||||||
Process? _qwenProcess;
|
Process? _qwenProcess;
|
||||||
Process? _nomicProcess;
|
Process? _nomicProcess;
|
||||||
AiServerStatus _status = AiServerStatus.offline;
|
AiServerStatus _status = AiServerStatus.offline;
|
||||||
String? _lastError;
|
String? _lastError;
|
||||||
|
String? _statusDetail;
|
||||||
|
|
||||||
|
// Last stderr lines per server — llama.cpp prints its actual failure
|
||||||
|
// reason (bad model file, out of memory, …) there, so keep a short tail
|
||||||
|
// to show the user instead of a generic "crashed" message.
|
||||||
|
final List<String> _qwenStderrTail = [];
|
||||||
|
final List<String> _nomicStderrTail = [];
|
||||||
|
static const int _stderrTailLines = 8;
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Public API
|
// Public API
|
||||||
@@ -28,6 +43,10 @@ class AiProcessManager extends ChangeNotifier {
|
|||||||
bool get isRunning => _status == AiServerStatus.ready;
|
bool get isRunning => _status == AiServerStatus.ready;
|
||||||
String? get errorMessage => _lastError;
|
String? get errorMessage => _lastError;
|
||||||
|
|
||||||
|
/// Live progress line while [status] is [AiServerStatus.starting],
|
||||||
|
/// e.g. "Loading AI model on GPU… 45s".
|
||||||
|
String? get statusDetail => _statusDetail;
|
||||||
|
|
||||||
/// Starts both inference servers. No-ops if already running or starting.
|
/// Starts both inference servers. No-ops if already running or starting.
|
||||||
Future<void> startServers() async {
|
Future<void> startServers() async {
|
||||||
if (_status == AiServerStatus.starting || _status == AiServerStatus.ready) {
|
if (_status == AiServerStatus.starting || _status == AiServerStatus.ready) {
|
||||||
@@ -48,97 +67,64 @@ class AiProcessManager extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_qwenProcess = await Process.start(serverBin, [
|
// Attempt 1: full GPU offload.
|
||||||
'-m', p.join(base, AiConstants.qwenModelFile),
|
final gpuReady = await _startAttempt(
|
||||||
'--port', '${AiConstants.chatServerPort}',
|
serverBin,
|
||||||
'--ctx-size', '${AiConstants.qwenContextSize}',
|
base,
|
||||||
'-ngl', '${AiConstants.gpuLayerOffload}',
|
gpuLayers: AiConstants.gpuLayerOffload,
|
||||||
], runInShell: false);
|
timeout: AiConstants.serverStartupTimeout,
|
||||||
|
detailLabel: 'GPU',
|
||||||
_qwenProcess!.stdout.listen((event) {
|
);
|
||||||
if (kDebugMode) print('[QWEN STDOUT] ${String.fromCharCodes(event)}');
|
if (gpuReady) {
|
||||||
});
|
_updateStatus(AiServerStatus.ready);
|
||||||
_qwenProcess!.stderr.listen((event) {
|
return;
|
||||||
if (kDebugMode) print('[QWEN STDERR] ${String.fromCharCodes(event)}');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Monitor for unexpected crash
|
|
||||||
_qwenProcess!.exitCode.then((code) {
|
|
||||||
if (_status == AiServerStatus.ready ||
|
|
||||||
_status == AiServerStatus.starting) {
|
|
||||||
_lastError = 'Qwen Chat Server crashed with code $code';
|
|
||||||
_updateStatus(AiServerStatus.error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
_nomicProcess = await Process.start(serverBin, [
|
|
||||||
'-m', p.join(base, AiConstants.nomicModelFile),
|
|
||||||
'--port', '${AiConstants.embeddingServerPort}',
|
|
||||||
'--ctx-size', '${AiConstants.nomicContextSize}',
|
|
||||||
'--embedding',
|
|
||||||
], runInShell: false);
|
|
||||||
|
|
||||||
_nomicProcess!.stdout.listen((event) {
|
|
||||||
if (kDebugMode) print('[NOMIC STDOUT] ${String.fromCharCodes(event)}');
|
|
||||||
});
|
|
||||||
_nomicProcess!.stderr.listen((event) {
|
|
||||||
if (kDebugMode) print('[NOMIC STDERR] ${String.fromCharCodes(event)}');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Monitor for unexpected crash
|
|
||||||
_nomicProcess!.exitCode.then((code) {
|
|
||||||
if (_status == AiServerStatus.ready ||
|
|
||||||
_status == AiServerStatus.starting) {
|
|
||||||
_lastError = 'Nomic Embedding Server crashed with code $code';
|
|
||||||
_updateStatus(AiServerStatus.error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for servers to bind to their ports and allocate memory.
|
|
||||||
// This is crucial because loading models (especially 7B) takes several
|
|
||||||
// seconds and significant RAM, which might cause the dart process to appear hung.
|
|
||||||
int attempts = 0;
|
|
||||||
bool qwenReady = false;
|
|
||||||
bool nomicReady = false;
|
|
||||||
|
|
||||||
while (attempts < 20 && (!qwenReady || !nomicReady)) {
|
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
|
||||||
|
|
||||||
if (!qwenReady) {
|
|
||||||
qwenReady = await _isPortReady(AiConstants.chatServerPort);
|
|
||||||
}
|
|
||||||
if (!nomicReady) {
|
|
||||||
nomicReady = await _isPortReady(AiConstants.embeddingServerPort);
|
|
||||||
}
|
|
||||||
|
|
||||||
attempts++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!qwenReady || !nomicReady) {
|
// Attempt 2: CPU only. Covers GPUs where Vulkan hangs, crashes
|
||||||
throw Exception('Servers failed to start within 10 seconds.');
|
// (e.g. out of VRAM) or pipeline compilation never finishes.
|
||||||
|
await _killProcesses();
|
||||||
|
_lastError = null;
|
||||||
|
_qwenStderrTail.clear();
|
||||||
|
_nomicStderrTail.clear();
|
||||||
|
final cpuReady = await _startAttempt(
|
||||||
|
serverBin,
|
||||||
|
base,
|
||||||
|
gpuLayers: 0,
|
||||||
|
timeout: AiConstants.serverStartupTimeoutCpu,
|
||||||
|
detailLabel: 'CPU',
|
||||||
|
);
|
||||||
|
if (cpuReady) {
|
||||||
|
_updateStatus(AiServerStatus.ready);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_updateStatus(AiServerStatus.ready);
|
throw Exception(
|
||||||
|
_lastError ??
|
||||||
|
'The AI server did not become ready (tried GPU, then CPU).'
|
||||||
|
'${_diagnostics()}',
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Clean up any partially-started processes before returning error.
|
// Clean up any partially-started processes before returning error.
|
||||||
_qwenProcess?.kill();
|
await _killProcesses();
|
||||||
_nomicProcess?.kill();
|
|
||||||
_qwenProcess = null;
|
|
||||||
_nomicProcess = null;
|
|
||||||
_lastError = e.toString();
|
_lastError = e.toString();
|
||||||
_updateStatus(AiServerStatus.error);
|
_updateStatus(AiServerStatus.error);
|
||||||
|
} finally {
|
||||||
|
_statusDetail = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kills both processes and resets the running flag.
|
/// Kills both processes and resets the running flag.
|
||||||
/// Safe to call even if servers were never started.
|
/// Safe to call even if servers were never started.
|
||||||
Future<void> stopServers() async {
|
Future<void> stopServers() async {
|
||||||
_qwenProcess?.kill();
|
await _killProcesses();
|
||||||
_nomicProcess?.kill();
|
|
||||||
|
|
||||||
if (Platform.isWindows) {
|
if (Platform.isWindows) {
|
||||||
try {
|
try {
|
||||||
await Process.run('taskkill', ['/F', '/IM', AiConstants.serverBinaryName]);
|
await Process.run('taskkill', [
|
||||||
|
'/F',
|
||||||
|
'/IM',
|
||||||
|
AiConstants.serverBinaryName,
|
||||||
|
]);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
} else if (Platform.isMacOS || Platform.isLinux) {
|
} else if (Platform.isMacOS || Platform.isLinux) {
|
||||||
try {
|
try {
|
||||||
@@ -146,25 +132,157 @@ class AiProcessManager extends ChangeNotifier {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
_qwenProcess = null;
|
|
||||||
_nomicProcess = null;
|
|
||||||
_updateStatus(AiServerStatus.offline);
|
_updateStatus(AiServerStatus.offline);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _isPortReady(int port) async {
|
// -------------------------------------------------------------------------
|
||||||
|
// Internals
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Spawns both servers with the given GPU offload and waits for health.
|
||||||
|
/// Returns false on timeout; rethrows nothing — a crashed process is
|
||||||
|
/// reported through [_lastError] by the exit watcher and detected here.
|
||||||
|
Future<bool> _startAttempt(
|
||||||
|
String serverBin,
|
||||||
|
String base, {
|
||||||
|
required int gpuLayers,
|
||||||
|
required Duration timeout,
|
||||||
|
required String detailLabel,
|
||||||
|
}) async {
|
||||||
|
_qwenProcess = await _spawn(
|
||||||
|
serverBin,
|
||||||
|
[
|
||||||
|
'-m', p.join(base, AiConstants.qwenModelFile),
|
||||||
|
'--port', '${AiConstants.chatServerPort}',
|
||||||
|
'--ctx-size', '${AiConstants.qwenContextSize}',
|
||||||
|
'-ngl', '$gpuLayers',
|
||||||
|
// Use the chat template embedded in the GGUF (required for
|
||||||
|
// correct Qwen3 formatting).
|
||||||
|
'--jinja',
|
||||||
|
],
|
||||||
|
label: 'QWEN',
|
||||||
|
stderrTail: _qwenStderrTail,
|
||||||
|
crashMessage: 'Qwen Chat Server crashed',
|
||||||
|
);
|
||||||
|
|
||||||
|
_nomicProcess = await _spawn(
|
||||||
|
serverBin,
|
||||||
|
[
|
||||||
|
'-m',
|
||||||
|
p.join(base, AiConstants.nomicModelFile),
|
||||||
|
'--port',
|
||||||
|
'${AiConstants.embeddingServerPort}',
|
||||||
|
'--ctx-size',
|
||||||
|
'${AiConstants.nomicContextSize}',
|
||||||
|
'--embedding',
|
||||||
|
],
|
||||||
|
label: 'NOMIC',
|
||||||
|
stderrTail: _nomicStderrTail,
|
||||||
|
crashMessage: 'Nomic Embedding Server crashed',
|
||||||
|
);
|
||||||
|
|
||||||
|
// llama-server binds its port almost immediately but returns 503 until
|
||||||
|
// the model is fully loaded, so poll /health rather than the TCP port.
|
||||||
|
final started = DateTime.now();
|
||||||
|
final deadline = started.add(timeout);
|
||||||
|
var qwenReady = false;
|
||||||
|
var nomicReady = false;
|
||||||
|
|
||||||
|
while (DateTime.now().isBefore(deadline) && (!qwenReady || !nomicReady)) {
|
||||||
|
// A crashed process will never become healthy — give up on this
|
||||||
|
// attempt right away (the caller may retry on CPU).
|
||||||
|
if (_lastError != null) return false;
|
||||||
|
|
||||||
|
await Future.delayed(AiConstants.healthPollInterval);
|
||||||
|
if (!qwenReady) {
|
||||||
|
qwenReady = await _isHealthy(AiConstants.chatHealthUrl);
|
||||||
|
}
|
||||||
|
if (!nomicReady) {
|
||||||
|
nomicReady = await _isHealthy(AiConstants.embeddingHealthUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
final elapsed = DateTime.now().difference(started).inSeconds;
|
||||||
|
_statusDetail =
|
||||||
|
'Loading AI model on $detailLabel… ${elapsed}s '
|
||||||
|
'(first run can take a few minutes)';
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
return qwenReady && nomicReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _killProcesses() async {
|
||||||
|
// Detach before killing so the exit watchers recognise an intentional
|
||||||
|
// shutdown and don't flag it as a crash.
|
||||||
|
final qwen = _qwenProcess;
|
||||||
|
final nomic = _nomicProcess;
|
||||||
|
_qwenProcess = null;
|
||||||
|
_nomicProcess = null;
|
||||||
|
qwen?.kill();
|
||||||
|
nomic?.kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Process> _spawn(
|
||||||
|
String binary,
|
||||||
|
List<String> args, {
|
||||||
|
required String label,
|
||||||
|
required List<String> stderrTail,
|
||||||
|
required String crashMessage,
|
||||||
|
}) async {
|
||||||
|
final process = await Process.start(binary, args, runInShell: false);
|
||||||
|
|
||||||
|
process.stdout.transform(utf8.decoder).listen((text) {
|
||||||
|
if (kDebugMode) debugPrint('[$label STDOUT] $text');
|
||||||
|
});
|
||||||
|
process.stderr.transform(utf8.decoder).listen((text) {
|
||||||
|
for (final line in const LineSplitter().convert(text)) {
|
||||||
|
if (line.trim().isEmpty) continue;
|
||||||
|
stderrTail.add(line);
|
||||||
|
if (stderrTail.length > _stderrTailLines) stderrTail.removeAt(0);
|
||||||
|
}
|
||||||
|
if (kDebugMode) debugPrint('[$label STDERR] $text');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Monitor for unexpected crash. Intentional kills (retry, shutdown)
|
||||||
|
// null out the process reference first, so they are ignored here.
|
||||||
|
process.exitCode.then((code) {
|
||||||
|
final isCurrent =
|
||||||
|
identical(process, _qwenProcess) || identical(process, _nomicProcess);
|
||||||
|
if (!isCurrent) return;
|
||||||
|
if (_status == AiServerStatus.ready ||
|
||||||
|
_status == AiServerStatus.starting) {
|
||||||
|
_lastError = '$crashMessage (exit code $code).${_diagnostics()}';
|
||||||
|
if (_status == AiServerStatus.ready) {
|
||||||
|
_updateStatus(AiServerStatus.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return process;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _isHealthy(String url) async {
|
||||||
try {
|
try {
|
||||||
final socket = await Socket.connect(
|
final response = await http
|
||||||
'127.0.0.1',
|
.get(Uri.parse(url))
|
||||||
port,
|
.timeout(const Duration(seconds: 2));
|
||||||
timeout: const Duration(seconds: 1),
|
return response.statusCode == 200;
|
||||||
);
|
|
||||||
socket.destroy();
|
|
||||||
return true;
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _diagnostics() {
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
if (_qwenStderrTail.isNotEmpty) {
|
||||||
|
buffer.write('\n\nChat server log:\n${_qwenStderrTail.join('\n')}');
|
||||||
|
}
|
||||||
|
if (_nomicStderrTail.isNotEmpty) {
|
||||||
|
buffer.write('\n\nEmbedding server log:\n${_nomicStderrTail.join('\n')}');
|
||||||
|
}
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
void _updateStatus(AiServerStatus newStatus) {
|
void _updateStatus(AiServerStatus newStatus) {
|
||||||
if (_status != newStatus) {
|
if (_status != newStatus) {
|
||||||
_status = newStatus;
|
_status = newStatus;
|
||||||
|
|||||||
160
lib/data/services/ai_settings_service.dart
Normal file
160
lib/data/services/ai_settings_service.dart
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
/// Which backend answers chat requests. Embeddings (knowledge base) always
|
||||||
|
/// stay on the local Nomic server regardless of this choice.
|
||||||
|
enum AiProvider { local, ollama, lmstudio, anthropic, openai, gemini }
|
||||||
|
|
||||||
|
extension AiProviderX on AiProvider {
|
||||||
|
String get label => switch (this) {
|
||||||
|
AiProvider.local => 'Built-in (Qwen3 4B)',
|
||||||
|
AiProvider.ollama => 'Ollama (local)',
|
||||||
|
AiProvider.lmstudio => 'LM Studio (local)',
|
||||||
|
AiProvider.anthropic => 'Anthropic (Claude)',
|
||||||
|
AiProvider.openai => 'OpenAI (GPT)',
|
||||||
|
AiProvider.gemini => 'Google (Gemini)',
|
||||||
|
};
|
||||||
|
|
||||||
|
String get defaultModel => switch (this) {
|
||||||
|
AiProvider.local => 'local',
|
||||||
|
AiProvider.ollama => 'qwen3:4b',
|
||||||
|
AiProvider.lmstudio => 'local-model',
|
||||||
|
AiProvider.anthropic => 'claude-opus-4-8',
|
||||||
|
AiProvider.openai => 'gpt-5.1',
|
||||||
|
AiProvider.gemini => 'gemini-2.5-flash',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Self-hosted OpenAI-compatible servers reachable over a base URL.
|
||||||
|
bool get isSelfHosted =>
|
||||||
|
this == AiProvider.ollama || this == AiProvider.lmstudio;
|
||||||
|
|
||||||
|
String get defaultBaseUrl => switch (this) {
|
||||||
|
AiProvider.ollama => 'http://localhost:11434',
|
||||||
|
AiProvider.lmstudio => 'http://localhost:1234',
|
||||||
|
_ => '',
|
||||||
|
};
|
||||||
|
|
||||||
|
bool get needsApiKey => switch (this) {
|
||||||
|
AiProvider.anthropic || AiProvider.openai || AiProvider.gemini => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class AiSettings {
|
||||||
|
const AiSettings({
|
||||||
|
this.provider = AiProvider.local,
|
||||||
|
this.apiKeys = const {},
|
||||||
|
this.models = const {},
|
||||||
|
this.baseUrls = const {},
|
||||||
|
});
|
||||||
|
|
||||||
|
final AiProvider provider;
|
||||||
|
|
||||||
|
/// API key per provider name — kept separately so switching providers
|
||||||
|
/// doesn't lose previously entered keys.
|
||||||
|
final Map<String, String> apiKeys;
|
||||||
|
|
||||||
|
/// Model override per provider name; falls back to [AiProviderX.defaultModel].
|
||||||
|
final Map<String, String> models;
|
||||||
|
|
||||||
|
/// Base URL override per provider name (Ollama / LM Studio);
|
||||||
|
/// falls back to [AiProviderX.defaultBaseUrl].
|
||||||
|
final Map<String, String> baseUrls;
|
||||||
|
|
||||||
|
String? apiKeyFor(AiProvider p) {
|
||||||
|
final key = apiKeys[p.name]?.trim();
|
||||||
|
return (key == null || key.isEmpty) ? null : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
String modelFor(AiProvider p) {
|
||||||
|
final model = models[p.name]?.trim();
|
||||||
|
return (model == null || model.isEmpty) ? p.defaultModel : model;
|
||||||
|
}
|
||||||
|
|
||||||
|
String baseUrlFor(AiProvider p) {
|
||||||
|
final url = baseUrls[p.name]?.trim();
|
||||||
|
final resolved = (url == null || url.isEmpty) ? p.defaultBaseUrl : url;
|
||||||
|
// Tolerate a trailing slash pasted in by the user.
|
||||||
|
return resolved.endsWith('/')
|
||||||
|
? resolved.substring(0, resolved.length - 1)
|
||||||
|
: resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when [provider] can serve chat: self-hosted and built-in servers
|
||||||
|
/// need no key, cloud providers do.
|
||||||
|
bool get isProviderConfigured =>
|
||||||
|
!provider.needsApiKey || apiKeyFor(provider) != null;
|
||||||
|
|
||||||
|
AiSettings copyWith({
|
||||||
|
AiProvider? provider,
|
||||||
|
Map<String, String>? apiKeys,
|
||||||
|
Map<String, String>? models,
|
||||||
|
Map<String, String>? baseUrls,
|
||||||
|
}) {
|
||||||
|
return AiSettings(
|
||||||
|
provider: provider ?? this.provider,
|
||||||
|
apiKeys: apiKeys ?? this.apiKeys,
|
||||||
|
models: models ?? this.models,
|
||||||
|
baseUrls: baseUrls ?? this.baseUrls,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'provider': provider.name,
|
||||||
|
'apiKeys': apiKeys,
|
||||||
|
'models': models,
|
||||||
|
'baseUrls': baseUrls,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory AiSettings.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AiSettings(
|
||||||
|
provider: AiProvider.values.firstWhere(
|
||||||
|
(p) => p.name == json['provider'],
|
||||||
|
orElse: () => AiProvider.local,
|
||||||
|
),
|
||||||
|
apiKeys: Map<String, String>.from(json['apiKeys'] as Map? ?? {}),
|
||||||
|
models: Map<String, String>.from(json['models'] as Map? ?? {}),
|
||||||
|
baseUrls: Map<String, String>.from(json['baseUrls'] as Map? ?? {}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads and persists [AiSettings] as a JSON file in the app documents dir.
|
||||||
|
class AiSettingsService extends ChangeNotifier {
|
||||||
|
static const _fileName = 'trainhub_ai_settings.json';
|
||||||
|
|
||||||
|
AiSettings _settings = const AiSettings();
|
||||||
|
AiSettings get settings => _settings;
|
||||||
|
|
||||||
|
Future<File> _file() async {
|
||||||
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
|
return File(p.join(dir.path, _fileName));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> load() async {
|
||||||
|
try {
|
||||||
|
final file = await _file();
|
||||||
|
if (!file.existsSync()) return;
|
||||||
|
final json = jsonDecode(await file.readAsString());
|
||||||
|
_settings = AiSettings.fromJson(json as Map<String, dynamic>);
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) debugPrint('Failed to load AI settings: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> save(AiSettings settings) async {
|
||||||
|
_settings = settings;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final file = await _file();
|
||||||
|
await file.writeAsString(jsonEncode(settings.toJson()));
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) debugPrint('Failed to save AI settings: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
204
lib/data/services/combo_service.dart
Normal file
204
lib/data/services/combo_service.dart
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
const _uuid = Uuid();
|
||||||
|
|
||||||
|
/// One technique node in a combo graph.
|
||||||
|
class ComboNode {
|
||||||
|
const ComboNode({
|
||||||
|
required this.id,
|
||||||
|
required this.technique,
|
||||||
|
this.translation = '',
|
||||||
|
required this.dx,
|
||||||
|
required this.dy,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String technique;
|
||||||
|
final String translation;
|
||||||
|
final double dx;
|
||||||
|
final double dy;
|
||||||
|
|
||||||
|
ComboNode copyWith({double? dx, double? dy}) => ComboNode(
|
||||||
|
id: id,
|
||||||
|
technique: technique,
|
||||||
|
translation: translation,
|
||||||
|
dx: dx ?? this.dx,
|
||||||
|
dy: dy ?? this.dy,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'technique': technique,
|
||||||
|
'translation': translation,
|
||||||
|
'dx': dx,
|
||||||
|
'dy': dy,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory ComboNode.fromJson(Map<String, dynamic> json) => ComboNode(
|
||||||
|
id: json['id'] as String,
|
||||||
|
technique: json['technique'] as String,
|
||||||
|
translation: (json['translation'] ?? '') as String,
|
||||||
|
dx: (json['dx'] as num).toDouble(),
|
||||||
|
dy: (json['dy'] as num).toDouble(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A directed transition between two techniques.
|
||||||
|
class ComboEdge {
|
||||||
|
const ComboEdge(this.fromId, this.toId);
|
||||||
|
|
||||||
|
final String fromId;
|
||||||
|
final String toId;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {'from': fromId, 'to': toId};
|
||||||
|
|
||||||
|
factory ComboEdge.fromJson(Map<String, dynamic> json) =>
|
||||||
|
ComboEdge(json['from'] as String, json['to'] as String);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A named technique graph: nodes are techniques, edges are the transitions
|
||||||
|
/// that make sense between them.
|
||||||
|
class Combo {
|
||||||
|
const Combo({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
this.nodes = const [],
|
||||||
|
this.edges = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final List<ComboNode> nodes;
|
||||||
|
final List<ComboEdge> edges;
|
||||||
|
|
||||||
|
Combo copyWith({
|
||||||
|
String? name,
|
||||||
|
List<ComboNode>? nodes,
|
||||||
|
List<ComboEdge>? edges,
|
||||||
|
}) => Combo(
|
||||||
|
id: id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
nodes: nodes ?? this.nodes,
|
||||||
|
edges: edges ?? this.edges,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'nodes': nodes.map((n) => n.toJson()).toList(),
|
||||||
|
'edges': edges.map((e) => e.toJson()).toList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
factory Combo.fromJson(Map<String, dynamic> json) => Combo(
|
||||||
|
id: json['id'] as String,
|
||||||
|
name: json['name'] as String,
|
||||||
|
nodes: [
|
||||||
|
for (final n in (json['nodes'] as List? ?? []))
|
||||||
|
ComboNode.fromJson(n as Map<String, dynamic>),
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
for (final e in (json['edges'] as List? ?? []))
|
||||||
|
ComboEdge.fromJson(e as Map<String, dynamic>),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Random walk over the graph — the generated combination to drill.
|
||||||
|
/// Follows outgoing edges; if a node is a dead end the walk stops there.
|
||||||
|
List<ComboNode> generateSequence({int length = 4, Random? random}) {
|
||||||
|
if (nodes.isEmpty) return [];
|
||||||
|
final rnd = random ?? Random();
|
||||||
|
final byId = {for (final n in nodes) n.id: n};
|
||||||
|
final outgoing = <String, List<String>>{};
|
||||||
|
for (final e in edges) {
|
||||||
|
outgoing.putIfAbsent(e.fromId, () => []).add(e.toId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer starting somewhere that actually leads on.
|
||||||
|
final starts = nodes.where((n) => outgoing.containsKey(n.id)).toList();
|
||||||
|
var current = (starts.isEmpty
|
||||||
|
? nodes
|
||||||
|
: starts)[rnd.nextInt((starts.isEmpty ? nodes : starts).length)];
|
||||||
|
|
||||||
|
final sequence = <ComboNode>[current];
|
||||||
|
while (sequence.length < length) {
|
||||||
|
final next = outgoing[current.id];
|
||||||
|
if (next == null || next.isEmpty) break;
|
||||||
|
current = byId[next[rnd.nextInt(next.length)]]!;
|
||||||
|
sequence.add(current);
|
||||||
|
}
|
||||||
|
return sequence;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads and persists combos as a JSON file in the app documents dir.
|
||||||
|
class ComboService extends ChangeNotifier {
|
||||||
|
static const _fileName = 'trainhub_combos.json';
|
||||||
|
|
||||||
|
List<Combo> _combos = [];
|
||||||
|
bool _loaded = false;
|
||||||
|
|
||||||
|
List<Combo> get combos => _combos;
|
||||||
|
|
||||||
|
Future<File> _file() async {
|
||||||
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
|
return File(p.join(dir.path, _fileName));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> ensureLoaded() async {
|
||||||
|
if (_loaded) return;
|
||||||
|
_loaded = true;
|
||||||
|
try {
|
||||||
|
final file = await _file();
|
||||||
|
if (!file.existsSync()) return;
|
||||||
|
final json = jsonDecode(await file.readAsString()) as List<dynamic>;
|
||||||
|
_combos = [
|
||||||
|
for (final c in json) Combo.fromJson(c as Map<String, dynamic>),
|
||||||
|
];
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) debugPrint('Failed to load combos: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Combo create(String name) {
|
||||||
|
final combo = Combo(id: _uuid.v4(), name: name);
|
||||||
|
_combos = [..._combos, combo];
|
||||||
|
notifyListeners();
|
||||||
|
_persist();
|
||||||
|
return combo;
|
||||||
|
}
|
||||||
|
|
||||||
|
void delete(String id) {
|
||||||
|
_combos = _combos.where((c) => c.id != id).toList();
|
||||||
|
notifyListeners();
|
||||||
|
_persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces a combo. Set [persist] false for high-frequency updates
|
||||||
|
/// (node dragging) and call [persistNow] once afterwards.
|
||||||
|
void update(Combo combo, {bool persist = true}) {
|
||||||
|
_combos = [for (final c in _combos) c.id == combo.id ? combo : c];
|
||||||
|
notifyListeners();
|
||||||
|
if (persist) _persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
void persistNow() => _persist();
|
||||||
|
|
||||||
|
Future<void> _persist() async {
|
||||||
|
try {
|
||||||
|
final file = await _file();
|
||||||
|
await file.writeAsString(
|
||||||
|
jsonEncode(_combos.map((c) => c.toJson()).toList()),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) debugPrint('Failed to save combos: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,10 +16,7 @@ class EmbeddingService {
|
|||||||
Future<List<double>> embed(String text) async {
|
Future<List<double>> embed(String text) async {
|
||||||
final response = await _dio.post<Map<String, dynamic>>(
|
final response = await _dio.post<Map<String, dynamic>>(
|
||||||
AiConstants.embeddingApiUrl,
|
AiConstants.embeddingApiUrl,
|
||||||
data: {
|
data: {'input': text, 'model': AiConstants.nomicModelName},
|
||||||
'input': text,
|
|
||||||
'model': AiConstants.nomicModelName,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final raw =
|
final raw =
|
||||||
|
|||||||
200
lib/data/services/llm_client.dart
Normal file
200
lib/data/services/llm_client.dart
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:trainhub_flutter/core/constants/ai_constants.dart';
|
||||||
|
import 'package:trainhub_flutter/data/services/ai_settings_service.dart';
|
||||||
|
|
||||||
|
/// Streams chat completions from the configured AI provider.
|
||||||
|
///
|
||||||
|
/// - Local llama.cpp, OpenAI and Gemini speak the OpenAI-compatible
|
||||||
|
/// `/chat/completions` SSE format.
|
||||||
|
/// - Anthropic uses its native `/v1/messages` SSE format.
|
||||||
|
///
|
||||||
|
/// Owns all HTTP/SSE plumbing so that controllers only deal with a plain
|
||||||
|
/// `Stream<String>` of content deltas.
|
||||||
|
class LlmClient {
|
||||||
|
LlmClient(this._settingsService, {Dio? dio})
|
||||||
|
: _dio =
|
||||||
|
dio ??
|
||||||
|
Dio(
|
||||||
|
BaseOptions(
|
||||||
|
connectTimeout: AiConstants.serverConnectTimeout,
|
||||||
|
receiveTimeout: AiConstants.serverReceiveTimeout,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final AiSettingsService _settingsService;
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
AiProvider get activeProvider => _settingsService.settings.provider;
|
||||||
|
|
||||||
|
/// Streams content deltas for a chat completion.
|
||||||
|
///
|
||||||
|
/// [messages] must already include the system prompt and history.
|
||||||
|
/// Pass a [cancelToken] to allow aborting generation mid-stream.
|
||||||
|
/// Throws [DioException] if the server is unreachable; errors that occur
|
||||||
|
/// mid-stream are surfaced through the returned stream.
|
||||||
|
Stream<String> streamChat(
|
||||||
|
List<Map<String, String>> messages, {
|
||||||
|
CancelToken? cancelToken,
|
||||||
|
}) {
|
||||||
|
final settings = _settingsService.settings;
|
||||||
|
return switch (settings.provider) {
|
||||||
|
AiProvider.anthropic => _streamAnthropic(
|
||||||
|
messages,
|
||||||
|
settings,
|
||||||
|
cancelToken: cancelToken,
|
||||||
|
),
|
||||||
|
_ => _streamOpenAiCompatible(
|
||||||
|
messages,
|
||||||
|
settings,
|
||||||
|
cancelToken: cancelToken,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// OpenAI-compatible providers: local llama.cpp, OpenAI, Gemini
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Stream<String> _streamOpenAiCompatible(
|
||||||
|
List<Map<String, String>> messages,
|
||||||
|
AiSettings settings, {
|
||||||
|
CancelToken? cancelToken,
|
||||||
|
}) async* {
|
||||||
|
final provider = settings.provider;
|
||||||
|
final isBuiltIn = provider == AiProvider.local;
|
||||||
|
|
||||||
|
final url = switch (provider) {
|
||||||
|
AiProvider.local => AiConstants.chatApiUrl,
|
||||||
|
AiProvider.ollama || AiProvider.lmstudio =>
|
||||||
|
'${settings.baseUrlFor(provider)}/v1/chat/completions',
|
||||||
|
AiProvider.openai => 'https://api.openai.com/v1/chat/completions',
|
||||||
|
AiProvider.gemini =>
|
||||||
|
'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions',
|
||||||
|
AiProvider.anthropic => throw StateError('handled separately'),
|
||||||
|
};
|
||||||
|
|
||||||
|
final apiKey = settings.apiKeyFor(provider);
|
||||||
|
if (provider.needsApiKey && apiKey == null) {
|
||||||
|
throw Exception('No API key configured for ${provider.label}.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await _dio.post<ResponseBody>(
|
||||||
|
url,
|
||||||
|
cancelToken: cancelToken,
|
||||||
|
options: Options(
|
||||||
|
responseType: ResponseType.stream,
|
||||||
|
headers: apiKey == null ? null : {'Authorization': 'Bearer $apiKey'},
|
||||||
|
),
|
||||||
|
data: {
|
||||||
|
if (!isBuiltIn) 'model': settings.modelFor(provider),
|
||||||
|
'messages': messages,
|
||||||
|
if (isBuiltIn) 'temperature': AiConstants.chatTemperature,
|
||||||
|
'max_tokens': AiConstants.chatMaxTokens,
|
||||||
|
// llama.cpp-specific: reuses the KV-cache of the shared prefix
|
||||||
|
// between turns — makes multi-turn conversations respond much faster.
|
||||||
|
if (isBuiltIn) 'cache_prompt': true,
|
||||||
|
'stream': true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await for (final line in _sseLines(response)) {
|
||||||
|
if (!line.startsWith('data: ')) continue;
|
||||||
|
final dataStr = line.substring(6).trim();
|
||||||
|
if (dataStr == '[DONE]') return;
|
||||||
|
if (dataStr.isEmpty) continue;
|
||||||
|
try {
|
||||||
|
final data = jsonDecode(dataStr);
|
||||||
|
final delta =
|
||||||
|
(data['choices']?[0]?['delta']?['content'] ?? '') as String;
|
||||||
|
if (delta.isNotEmpty) yield delta;
|
||||||
|
} catch (_) {
|
||||||
|
// Malformed line — dropped. Thanks to the line buffering this only
|
||||||
|
// happens on genuinely corrupt data, not on chunk boundaries.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Anthropic native Messages API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Stream<String> _streamAnthropic(
|
||||||
|
List<Map<String, String>> messages,
|
||||||
|
AiSettings settings, {
|
||||||
|
CancelToken? cancelToken,
|
||||||
|
}) async* {
|
||||||
|
final apiKey = settings.apiKeyFor(AiProvider.anthropic);
|
||||||
|
if (apiKey == null) {
|
||||||
|
throw Exception('No API key configured for Anthropic.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anthropic takes the system prompt as a top-level field, not a message.
|
||||||
|
final system = messages
|
||||||
|
.where((m) => m['role'] == 'system')
|
||||||
|
.map((m) => m['content'])
|
||||||
|
.join('\n\n');
|
||||||
|
final chat = messages.where((m) => m['role'] != 'system').toList();
|
||||||
|
|
||||||
|
final response = await _dio.post<ResponseBody>(
|
||||||
|
'https://api.anthropic.com/v1/messages',
|
||||||
|
cancelToken: cancelToken,
|
||||||
|
options: Options(
|
||||||
|
responseType: ResponseType.stream,
|
||||||
|
headers: {'x-api-key': apiKey, 'anthropic-version': '2023-06-01'},
|
||||||
|
),
|
||||||
|
data: {
|
||||||
|
'model': settings.modelFor(AiProvider.anthropic),
|
||||||
|
'max_tokens': AiConstants.chatMaxTokens,
|
||||||
|
if (system.isNotEmpty) 'system': system,
|
||||||
|
'messages': chat,
|
||||||
|
'stream': true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await for (final line in _sseLines(response)) {
|
||||||
|
if (!line.startsWith('data: ')) continue;
|
||||||
|
final dataStr = line.substring(6).trim();
|
||||||
|
if (dataStr.isEmpty) continue;
|
||||||
|
try {
|
||||||
|
final data = jsonDecode(dataStr);
|
||||||
|
final type = data['type'] as String?;
|
||||||
|
if (type == 'content_block_delta') {
|
||||||
|
final delta = data['delta'];
|
||||||
|
if (delta?['type'] == 'text_delta') {
|
||||||
|
final text = (delta['text'] ?? '') as String;
|
||||||
|
if (text.isNotEmpty) yield text;
|
||||||
|
}
|
||||||
|
} else if (type == 'message_stop') {
|
||||||
|
return;
|
||||||
|
} else if (type == 'error') {
|
||||||
|
throw Exception(
|
||||||
|
'Anthropic API error: ${data['error']?['message'] ?? 'unknown'}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} on FormatException {
|
||||||
|
// Malformed line — dropped.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SSE line splitting with chunk-boundary buffering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// SSE lines can be split across TCP chunks, so carry the unfinished tail
|
||||||
|
/// of each chunk over to the next one instead of dropping it.
|
||||||
|
Stream<String> _sseLines(Response<ResponseBody> response) async* {
|
||||||
|
var pending = '';
|
||||||
|
await for (final chunk in response.data!.stream) {
|
||||||
|
pending += utf8.decode(chunk, allowMalformed: true);
|
||||||
|
final lines = pending.split('\n');
|
||||||
|
pending = lines.removeLast();
|
||||||
|
for (final line in lines) {
|
||||||
|
yield line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pending.isNotEmpty) yield pending;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,10 @@ import 'package:trainhub_flutter/data/repositories/chat_repository_impl.dart';
|
|||||||
import 'package:trainhub_flutter/data/repositories/note_repository_impl.dart';
|
import 'package:trainhub_flutter/data/repositories/note_repository_impl.dart';
|
||||||
import 'package:trainhub_flutter/domain/repositories/note_repository.dart';
|
import 'package:trainhub_flutter/domain/repositories/note_repository.dart';
|
||||||
import 'package:trainhub_flutter/data/services/ai_process_manager.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/combo_service.dart';
|
||||||
import 'package:trainhub_flutter/data/services/embedding_service.dart';
|
import 'package:trainhub_flutter/data/services/embedding_service.dart';
|
||||||
|
import 'package:trainhub_flutter/data/services/llm_client.dart';
|
||||||
import 'package:trainhub_flutter/data/database/daos/knowledge_chunk_dao.dart';
|
import 'package:trainhub_flutter/data/database/daos/knowledge_chunk_dao.dart';
|
||||||
|
|
||||||
final GetIt getIt = GetIt.instance;
|
final GetIt getIt = GetIt.instance;
|
||||||
@@ -45,6 +48,9 @@ void init() {
|
|||||||
|
|
||||||
// Services
|
// Services
|
||||||
getIt.registerSingleton<EmbeddingService>(EmbeddingService());
|
getIt.registerSingleton<EmbeddingService>(EmbeddingService());
|
||||||
|
getIt.registerSingleton<AiSettingsService>(AiSettingsService());
|
||||||
|
getIt.registerSingleton<ComboService>(ComboService());
|
||||||
|
getIt.registerSingleton<LlmClient>(LlmClient(getIt<AiSettingsService>()));
|
||||||
|
|
||||||
// Repositories
|
// Repositories
|
||||||
getIt.registerLazySingleton<ExerciseRepository>(
|
getIt.registerLazySingleton<ExerciseRepository>(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:window_manager/window_manager.dart';
|
|||||||
import 'package:trainhub_flutter/core/router/app_router.dart';
|
import 'package:trainhub_flutter/core/router/app_router.dart';
|
||||||
import 'package:trainhub_flutter/core/theme/app_theme.dart';
|
import 'package:trainhub_flutter/core/theme/app_theme.dart';
|
||||||
import 'package:trainhub_flutter/data/services/ai_process_manager.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/injection.dart' as di;
|
import 'package:trainhub_flutter/injection.dart' as di;
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
@@ -13,6 +14,7 @@ void main() async {
|
|||||||
await windowManager.ensureInitialized();
|
await windowManager.ensureInitialized();
|
||||||
|
|
||||||
di.init();
|
di.init();
|
||||||
|
await di.getIt<AiSettingsService>().load();
|
||||||
|
|
||||||
const windowOptions = WindowOptions(
|
const windowOptions = WindowOptions(
|
||||||
size: Size(1280, 800),
|
size: Size(1280, 800),
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:trainhub_flutter/core/constants/ai_constants.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/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/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_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/injection.dart';
|
||||||
import 'package:trainhub_flutter/presentation/chat/chat_state.dart';
|
import 'package:trainhub_flutter/presentation/chat/chat_state.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
@@ -18,22 +21,27 @@ AiProcessManager aiProcessManager(AiProcessManagerRef ref) {
|
|||||||
return manager;
|
return manager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
AiSettingsService aiSettingsService(AiSettingsServiceRef ref) {
|
||||||
|
final service = getIt<AiSettingsService>();
|
||||||
|
service.addListener(() => ref.notifyListeners());
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
@riverpod
|
@riverpod
|
||||||
class ChatController extends _$ChatController {
|
class ChatController extends _$ChatController {
|
||||||
late ChatRepository _repo;
|
late ChatRepository _repo;
|
||||||
late NoteRepository _noteRepo;
|
late NoteRepository _noteRepo;
|
||||||
|
late LlmClient _llm;
|
||||||
final _dio = Dio(
|
CancelToken? _cancelToken;
|
||||||
BaseOptions(
|
|
||||||
connectTimeout: AiConstants.serverConnectTimeout,
|
|
||||||
receiveTimeout: AiConstants.serverReceiveTimeout,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ChatState> build() async {
|
Future<ChatState> build() async {
|
||||||
_repo = getIt<ChatRepository>();
|
_repo = getIt<ChatRepository>();
|
||||||
_noteRepo = getIt<NoteRepository>();
|
_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);
|
final aiManager = ref.read(aiProcessManagerProvider);
|
||||||
if (aiManager.status == AiServerStatus.offline) {
|
if (aiManager.status == AiServerStatus.offline) {
|
||||||
aiManager.startServers();
|
aiManager.startServers();
|
||||||
@@ -67,8 +75,9 @@ class ChatController extends _$ChatController {
|
|||||||
state = AsyncValue.data(
|
state = AsyncValue.data(
|
||||||
current.copyWith(
|
current.copyWith(
|
||||||
sessions: sessions,
|
sessions: sessions,
|
||||||
activeSession:
|
activeSession: current.activeSession?.id == id
|
||||||
current.activeSession?.id == id ? null : current.activeSession,
|
? null
|
||||||
|
: current.activeSession,
|
||||||
messages: current.activeSession?.id == id ? [] : current.messages,
|
messages: current.activeSession?.id == id ? [] : current.messages,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -80,12 +89,44 @@ class ChatController extends _$ChatController {
|
|||||||
final sessionId = await _resolveSession(current, content);
|
final sessionId = await _resolveSession(current, content);
|
||||||
await _persistUserMessage(sessionId, content);
|
await _persistUserMessage(sessionId, content);
|
||||||
final contextChunks = await _searchKnowledgeBase(content);
|
final contextChunks = await _searchKnowledgeBase(content);
|
||||||
final systemPrompt = _buildSystemPrompt(contextChunks);
|
final trainingContext = await _buildTrainingContext();
|
||||||
|
final systemPrompt = _buildSystemPrompt(contextChunks, trainingContext);
|
||||||
final history = _buildHistory();
|
final history = _buildHistory();
|
||||||
final fullAiResponse = await _streamResponse(systemPrompt, history);
|
final fullAiResponse = await _streamResponse(systemPrompt, history);
|
||||||
await _persistAssistantResponse(sessionId, content, fullAiResponse);
|
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 {
|
Future<String> _resolveSession(ChatState current, String content) async {
|
||||||
if (current.activeSession != null) return current.activeSession!.id;
|
if (current.activeSession != null) return current.activeSession!.id;
|
||||||
final session = await _repo.createSession();
|
final session = await _repo.createSession();
|
||||||
@@ -144,61 +185,51 @@ class ChatController extends _$ChatController {
|
|||||||
return contextChunks;
|
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() {
|
List<Map<String, String>> _buildHistory() {
|
||||||
final messages = state.valueOrNull?.messages ?? [];
|
final messages = state.valueOrNull?.messages ?? [];
|
||||||
return messages
|
final recent = messages.length > AiConstants.chatHistoryLimit
|
||||||
.map((m) => <String, String>{
|
? messages.sublist(messages.length - AiConstants.chatHistoryLimit)
|
||||||
'role': m.isUser ? 'user' : 'assistant',
|
: messages;
|
||||||
'content': m.content,
|
return recent
|
||||||
})
|
.map(
|
||||||
|
(m) => <String, String>{
|
||||||
|
'role': m.isUser ? 'user' : 'assistant',
|
||||||
|
'content': m.content,
|
||||||
|
},
|
||||||
|
)
|
||||||
.toList();
|
.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(
|
Future<String> _streamResponse(
|
||||||
String systemPrompt,
|
String systemPrompt,
|
||||||
List<Map<String, String>> history,
|
List<Map<String, String>> history,
|
||||||
) async {
|
) async {
|
||||||
final generateStep = _createStep('Generating response...');
|
final generateStep = _createStep('Generating response...');
|
||||||
String fullAiResponse = '';
|
String fullAiResponse = '';
|
||||||
|
_cancelToken = CancelToken();
|
||||||
try {
|
try {
|
||||||
final response = await _dio.post<ResponseBody>(
|
final stream = _llm.streamChat([
|
||||||
AiConstants.chatApiUrl,
|
{'role': 'system', 'content': systemPrompt},
|
||||||
options: Options(responseType: ResponseType.stream),
|
...history,
|
||||||
data: {
|
], cancelToken: _cancelToken);
|
||||||
'messages': [
|
|
||||||
{'role': 'system', 'content': systemPrompt},
|
|
||||||
...history,
|
|
||||||
],
|
|
||||||
'temperature': AiConstants.chatTemperature,
|
|
||||||
'stream': true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
_updateStep(
|
_updateStep(
|
||||||
generateStep.id,
|
generateStep.id,
|
||||||
status: ThinkingStepStatus.running,
|
status: ThinkingStepStatus.running,
|
||||||
title: 'Writing...',
|
title: 'Writing...',
|
||||||
);
|
);
|
||||||
final stream = response.data!.stream;
|
await for (final delta in stream) {
|
||||||
await for (final chunk in stream) {
|
fullAiResponse += delta;
|
||||||
final textChunk = utf8.decode(chunk);
|
final updatedState = state.valueOrNull;
|
||||||
for (final line in textChunk.split('\n')) {
|
if (updatedState != null) {
|
||||||
if (!line.startsWith('data: ')) continue;
|
state = AsyncValue.data(
|
||||||
final dataStr = line.substring(6).trim();
|
updatedState.copyWith(streamingContent: fullAiResponse),
|
||||||
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 (_) {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_updateStep(
|
_updateStep(
|
||||||
@@ -207,21 +238,29 @@ class ChatController extends _$ChatController {
|
|||||||
title: 'Response generated',
|
title: 'Response generated',
|
||||||
);
|
);
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
fullAiResponse += '\n\n[AI model communication error]';
|
if (CancelToken.isCancel(e)) {
|
||||||
_updateStep(
|
_updateStep(
|
||||||
generateStep.id,
|
generateStep.id,
|
||||||
status: ThinkingStepStatus.error,
|
status: ThinkingStepStatus.completed,
|
||||||
title: 'Generation failed',
|
title: 'Stopped by user',
|
||||||
details: '${e.message}',
|
);
|
||||||
);
|
} else {
|
||||||
|
_updateStep(
|
||||||
|
generateStep.id,
|
||||||
|
status: ThinkingStepStatus.error,
|
||||||
|
title: 'Generation failed',
|
||||||
|
details: e.message ?? e.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
fullAiResponse += '\n\n[Unexpected error]';
|
|
||||||
_updateStep(
|
_updateStep(
|
||||||
generateStep.id,
|
generateStep.id,
|
||||||
status: ThinkingStepStatus.error,
|
status: ThinkingStepStatus.error,
|
||||||
title: 'Generation failed',
|
title: 'Generation failed',
|
||||||
details: e.toString(),
|
details: e.toString(),
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
_cancelToken = null;
|
||||||
}
|
}
|
||||||
return fullAiResponse;
|
return fullAiResponse;
|
||||||
}
|
}
|
||||||
@@ -231,6 +270,17 @@ class ChatController extends _$ChatController {
|
|||||||
String userContent,
|
String userContent,
|
||||||
String aiResponse,
|
String aiResponse,
|
||||||
) async {
|
) 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(
|
await _repo.addMessage(
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
@@ -289,18 +339,32 @@ class ChatController extends _$ChatController {
|
|||||||
state = AsyncValue.data(current.copyWith(thinkingSteps: updatedSteps));
|
state = AsyncValue.data(current.copyWith(thinkingSteps: updatedSteps));
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _buildSystemPrompt(List<String> chunks) {
|
static String _buildSystemPrompt(
|
||||||
if (chunks.isEmpty) return AiConstants.baseSystemPrompt;
|
List<String> chunks,
|
||||||
final contextBlock = chunks
|
String trainingContext,
|
||||||
.asMap()
|
) {
|
||||||
.entries
|
final buffer = StringBuffer(AiConstants.baseSystemPrompt);
|
||||||
.map((e) => '[${e.key + 1}] ${e.value}')
|
if (trainingContext.isNotEmpty) {
|
||||||
.join('\n\n');
|
buffer.write('\n\n$trainingContext');
|
||||||
return '${AiConstants.baseSystemPrompt}\n\n'
|
buffer.write(
|
||||||
'### Relevant notes from the trainer\'s knowledge base:\n'
|
'\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'
|
'$contextBlock\n\n'
|
||||||
'Use the above context to inform your response when relevant. '
|
'Use the above context to inform your response when relevant. '
|
||||||
'If the context is not directly applicable, rely on your general '
|
'If the context is not directly applicable, rely on your general '
|
||||||
'fitness knowledge.';
|
'fitness knowledge.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return buffer.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,25 @@ final aiProcessManagerProvider = AutoDisposeProvider<AiProcessManager>.internal(
|
|||||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||||
// ignore: unused_element
|
// ignore: unused_element
|
||||||
typedef AiProcessManagerRef = AutoDisposeProviderRef<AiProcessManager>;
|
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].
|
/// See also [ChatController].
|
||||||
@ProviderFor(ChatController)
|
@ProviderFor(ChatController)
|
||||||
|
|||||||
@@ -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/constants/ui_constants.dart';
|
||||||
import 'package:trainhub_flutter/core/theme/app_colors.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_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/domain/entities/chat_session.dart';
|
||||||
import 'package:trainhub_flutter/presentation/chat/chat_controller.dart';
|
import 'package:trainhub_flutter/presentation/chat/chat_controller.dart';
|
||||||
import 'package:trainhub_flutter/presentation/chat/chat_state.dart';
|
import 'package:trainhub_flutter/presentation/chat/chat_state.dart';
|
||||||
@@ -63,9 +64,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final hour = dt.hour.toString().padLeft(2, '0');
|
final hour = dt.hour.toString().padLeft(2, '0');
|
||||||
final minute = dt.minute.toString().padLeft(2, '0');
|
final minute = dt.minute.toString().padLeft(2, '0');
|
||||||
if (dt.year == now.year &&
|
if (dt.year == now.year && dt.month == now.month && dt.day == now.day) {
|
||||||
dt.month == now.month &&
|
|
||||||
dt.day == now.day) {
|
|
||||||
return '$hour:$minute';
|
return '$hour:$minute';
|
||||||
}
|
}
|
||||||
return '${dt.day}/${dt.month} $hour:$minute';
|
return '${dt.day}/${dt.month} $hour:$minute';
|
||||||
@@ -76,14 +75,19 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final modelsValidated =
|
final modelsValidated = ref
|
||||||
ref.watch(aiModelSettingsControllerProvider).areModelsValidated;
|
.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 state = ref.watch(chatControllerProvider);
|
||||||
final controller = ref.read(chatControllerProvider.notifier);
|
final controller = ref.read(chatControllerProvider.notifier);
|
||||||
ref.listen(chatControllerProvider, (prev, next) {
|
ref.listen(chatControllerProvider, (prev, next) {
|
||||||
if (next.hasValue &&
|
if (next.hasValue &&
|
||||||
(prev?.value?.messages.length ?? 0) <
|
(prev?.value?.messages.length ?? 0) < next.value!.messages.length) {
|
||||||
next.value!.messages.length) {
|
|
||||||
_scrollToBottom();
|
_scrollToBottom();
|
||||||
}
|
}
|
||||||
if (next.hasValue &&
|
if (next.hasValue &&
|
||||||
@@ -92,7 +96,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
_scrollToBottom();
|
_scrollToBottom();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (!modelsValidated) {
|
if (!modelsValidated && !cloudConfigured) {
|
||||||
return const Scaffold(
|
return const Scaffold(
|
||||||
backgroundColor: AppColors.surface,
|
backgroundColor: AppColors.surface,
|
||||||
body: MissingModelsState(),
|
body: MissingModelsState(),
|
||||||
@@ -211,14 +215,11 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
color: isActive
|
color: isActive
|
||||||
? AppColors.zinc700.withValues(alpha: 0.7)
|
? AppColors.zinc700.withValues(alpha: 0.7)
|
||||||
: isHovered
|
: isHovered
|
||||||
? AppColors.zinc800.withValues(alpha: 0.6)
|
? AppColors.zinc800.withValues(alpha: 0.6)
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
borderRadius:
|
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
|
||||||
border: isActive
|
border: isActive
|
||||||
? Border.all(
|
? Border.all(color: AppColors.accent.withValues(alpha: 0.3))
|
||||||
color: AppColors.accent.withValues(alpha: 0.3),
|
|
||||||
)
|
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -239,8 +240,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
? AppColors.textPrimary
|
? AppColors.textPrimary
|
||||||
: AppColors.textSecondary,
|
: AppColors.textSecondary,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight:
|
fontWeight: isActive ? FontWeight.w500 : FontWeight.normal,
|
||||||
isActive ? FontWeight.w500 : FontWeight.normal,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -260,8 +260,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
Icons.delete_outline_rounded,
|
Icons.delete_outline_rounded,
|
||||||
color: AppColors.textMuted,
|
color: AppColors.textMuted,
|
||||||
),
|
),
|
||||||
onPressed: () =>
|
onPressed: () => controller.deleteSession(session.id),
|
||||||
controller.deleteSession(session.id),
|
|
||||||
tooltip: 'Delete',
|
tooltip: 'Delete',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -290,8 +289,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
horizontal: UIConstants.spacing24,
|
horizontal: UIConstants.spacing24,
|
||||||
vertical: UIConstants.spacing16,
|
vertical: UIConstants.spacing16,
|
||||||
),
|
),
|
||||||
itemCount:
|
itemCount: data.messages.length + (data.isTyping ? 1 : 0),
|
||||||
data.messages.length + (data.isTyping ? 1 : 0),
|
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
if (index == data.messages.length) {
|
if (index == data.messages.length) {
|
||||||
if (data.thinkingSteps.isNotEmpty ||
|
if (data.thinkingSteps.isNotEmpty ||
|
||||||
@@ -391,21 +389,46 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
ChatController controller,
|
ChatController controller,
|
||||||
) {
|
) {
|
||||||
final aiManager = ref.watch(aiProcessManagerProvider);
|
final aiManager = ref.watch(aiProcessManagerProvider);
|
||||||
|
final aiSettings = ref.watch(aiSettingsServiceProvider).settings;
|
||||||
final isTyping = asyncState.valueOrNull?.isTyping ?? false;
|
final isTyping = asyncState.valueOrNull?.isTyping ?? false;
|
||||||
final isStarting = aiManager.status == AiServerStatus.starting;
|
final isStarting = aiManager.status == AiServerStatus.starting;
|
||||||
final isError = aiManager.status == AiServerStatus.error;
|
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;
|
String? statusMessage;
|
||||||
Color statusColor = AppColors.textMuted;
|
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 =
|
statusMessage =
|
||||||
|
aiManager.statusDetail ??
|
||||||
'Starting AI inference server (this may take a moment)...';
|
'Starting AI inference server (this may take a moment)...';
|
||||||
statusColor = AppColors.info;
|
statusColor = AppColors.info;
|
||||||
} else if (isError) {
|
} else if (isError) {
|
||||||
statusMessage =
|
// The full error contains multi-line server logs — show only the
|
||||||
'AI Server Error: ${aiManager.errorMessage ?? "Unknown error"}';
|
// 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;
|
statusColor = AppColors.destructive;
|
||||||
} else if (!isReady) {
|
} else if (aiManager.status != AiServerStatus.ready) {
|
||||||
statusMessage = 'AI Server offline. Reconnecting...';
|
statusMessage = 'AI Server offline. Reconnecting...';
|
||||||
}
|
}
|
||||||
return Container(
|
return Container(
|
||||||
@@ -424,7 +447,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
padding: const EdgeInsets.only(bottom: UIConstants.spacing8),
|
padding: const EdgeInsets.only(bottom: UIConstants.spacing8),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
if (isStarting)
|
if (!isCloud && isStarting)
|
||||||
Container(
|
Container(
|
||||||
margin: const EdgeInsets.only(right: 8),
|
margin: const EdgeInsets.only(right: 8),
|
||||||
width: 12,
|
width: 12,
|
||||||
@@ -434,7 +457,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
color: AppColors.info,
|
color: AppColors.info,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else if (isError)
|
else if (!isCloud && isError)
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.only(right: 8),
|
padding: EdgeInsets.only(right: 8),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@@ -453,7 +476,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (isError)
|
if (!isCloud && isError)
|
||||||
TextButton(
|
TextButton(
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
@@ -479,10 +502,10 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.surfaceContainer,
|
color: AppColors.surfaceContainer,
|
||||||
borderRadius:
|
borderRadius: BorderRadius.circular(
|
||||||
BorderRadius.circular(UIConstants.borderRadius),
|
UIConstants.borderRadius,
|
||||||
border:
|
),
|
||||||
Border.all(color: AppColors.border, width: 1),
|
border: Border.all(color: AppColors.border, width: 1),
|
||||||
),
|
),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _inputController,
|
controller: _inputController,
|
||||||
@@ -525,22 +548,23 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: (isTyping || !isReady)
|
// While generating, the button becomes a stop control.
|
||||||
? AppColors.zinc700
|
color: !isReady ? AppColors.zinc700 : AppColors.accent,
|
||||||
: AppColors.accent,
|
borderRadius: BorderRadius.circular(UIConstants.borderRadius),
|
||||||
borderRadius:
|
|
||||||
BorderRadius.circular(UIConstants.borderRadius),
|
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius:
|
borderRadius: BorderRadius.circular(
|
||||||
BorderRadius.circular(UIConstants.borderRadius),
|
UIConstants.borderRadius,
|
||||||
onTap: (isTyping || !isReady)
|
),
|
||||||
|
onTap: !isReady
|
||||||
? null
|
? null
|
||||||
|
: isTyping
|
||||||
|
? controller.stopGeneration
|
||||||
: () => _sendMessage(controller),
|
: () => _sendMessage(controller),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.arrow_upward_rounded,
|
isTyping
|
||||||
color: (isTyping || !isReady)
|
? Icons.stop_rounded
|
||||||
? AppColors.textMuted
|
: Icons.arrow_upward_rounded,
|
||||||
: AppColors.zinc950,
|
color: !isReady ? AppColors.textMuted : AppColors.zinc950,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -29,11 +29,7 @@ class AppStatCard extends StatelessWidget {
|
|||||||
borderRadius: UIConstants.cardBorderRadius,
|
borderRadius: UIConstants.cardBorderRadius,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(width: 4, height: 72, color: accentColor),
|
||||||
width: 4,
|
|
||||||
height: 72,
|
|
||||||
color: accentColor,
|
|
||||||
),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
@@ -48,17 +44,18 @@ class AppStatCard extends StatelessWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
title,
|
title.toUpperCase(),
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.jetBrainsMono(
|
||||||
fontSize: 12,
|
fontSize: 10,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 1.2,
|
||||||
color: AppColors.textMuted,
|
color: AppColors.textMuted,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: UIConstants.spacing4),
|
const SizedBox(height: UIConstants.spacing4),
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.jetBrainsMono(
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
@@ -67,12 +64,7 @@ class AppStatCard extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (icon != null)
|
if (icon != null) Icon(icon, size: 20, color: accentColor),
|
||||||
Icon(
|
|
||||||
icon,
|
|
||||||
size: 20,
|
|
||||||
color: accentColor,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
405
lib/presentation/common/widgets/node_graph_canvas.dart
Normal file
405
lib/presentation/common/widgets/node_graph_canvas.dart
Normal 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;
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ class NextWorkoutBanner extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.surfaceContainer,
|
color: AppColors.surfaceContainer,
|
||||||
borderRadius: UIConstants.cardBorderRadius,
|
borderRadius: UIConstants.cardBorderRadius,
|
||||||
border: Border.all(color: AppColors.border),
|
border: Border.all(color: AppColors.accentBorder),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -24,12 +24,13 @@ class NextWorkoutBanner extends StatelessWidget {
|
|||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.accentMuted,
|
color: AppColors.accent,
|
||||||
borderRadius: UIConstants.smallCardBorderRadius,
|
borderRadius: UIConstants.smallCardBorderRadius,
|
||||||
|
boxShadow: UIConstants.accentGlow,
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.play_arrow_rounded,
|
Icons.play_arrow_rounded,
|
||||||
color: AppColors.accent,
|
color: AppColors.zinc950,
|
||||||
size: 22,
|
size: 22,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -39,30 +40,28 @@ class NextWorkoutBanner extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Up Next',
|
'UP NEXT',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.jetBrainsMono(
|
||||||
fontSize: 12,
|
fontSize: 10,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textMuted,
|
letterSpacing: 1.5,
|
||||||
|
color: AppColors.accent,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
workoutName,
|
workoutName.toUpperCase(),
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.chakraPetch(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.5,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Icon(
|
const Icon(Icons.chevron_right, color: AppColors.textMuted, size: 20),
|
||||||
Icons.chevron_right,
|
|
||||||
color: AppColors.textMuted,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,11 +14,12 @@ class WelcomeHeader extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Welcome back',
|
'WELCOME BACK',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.jetBrainsMono(
|
||||||
fontSize: 14,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textMuted,
|
letterSpacing: 2,
|
||||||
|
color: AppColors.accent,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: UIConstants.spacing4),
|
const SizedBox(height: UIConstants.spacing4),
|
||||||
@@ -26,10 +27,11 @@ class WelcomeHeader extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
programName,
|
programName.toUpperCase(),
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.chakraPetch(
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.5,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -42,21 +44,23 @@ class WelcomeHeader extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.accentMuted,
|
color: AppColors.accentMuted,
|
||||||
borderRadius: UIConstants.smallCardBorderRadius,
|
borderRadius: UIConstants.smallCardBorderRadius,
|
||||||
|
border: Border.all(color: AppColors.accentBorder),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
const Icon(
|
||||||
Icons.fitness_center,
|
Icons.sports_martial_arts,
|
||||||
size: 14,
|
size: 14,
|
||||||
color: AppColors.accent,
|
color: AppColors.accent,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
'Active Program',
|
'ACTIVE PROGRAM',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.jetBrainsMono(
|
||||||
fontSize: 12,
|
fontSize: 10,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 1.5,
|
||||||
color: AppColors.accent,
|
color: AppColors.accent,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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/constants/ui_constants.dart';
|
||||||
import 'package:trainhub_flutter/core/theme/app_colors.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/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';
|
import 'package:trainhub_flutter/presentation/plan_editor/widgets/plan_section_card.dart';
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
class PlanEditorPage extends ConsumerWidget {
|
class PlanEditorPage extends ConsumerStatefulWidget {
|
||||||
final String planId;
|
final String planId;
|
||||||
|
|
||||||
const PlanEditorPage({super.key, @PathParam('planId') required this.planId});
|
const PlanEditorPage({super.key, @PathParam('planId') required this.planId});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<PlanEditorPage> createState() => _PlanEditorPageState();
|
||||||
final state = ref.watch(planEditorControllerProvider(planId));
|
}
|
||||||
final controller = ref.read(planEditorControllerProvider(planId).notifier);
|
|
||||||
|
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(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.surface,
|
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
|
// Unsaved changes badge + save button
|
||||||
state.maybeWhen(
|
state.maybeWhen(
|
||||||
data: (data) => data.isDirty
|
data: (data) => data.isDirty
|
||||||
@@ -115,7 +132,9 @@ class PlanEditorPage extends ConsumerWidget {
|
|||||||
vertical: 4,
|
vertical: 4,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.warning.withValues(alpha: 0.12),
|
color: AppColors.warning.withValues(
|
||||||
|
alpha: 0.12,
|
||||||
|
),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppColors.warning.withValues(
|
color: AppColors.warning.withValues(
|
||||||
@@ -150,41 +169,50 @@ class PlanEditorPage extends ConsumerWidget {
|
|||||||
// --- Body ---
|
// --- Body ---
|
||||||
Expanded(
|
Expanded(
|
||||||
child: state.when(
|
child: state.when(
|
||||||
data: (data) => ReorderableListView.builder(
|
data: (data) => _graphView
|
||||||
padding: const EdgeInsets.all(UIConstants.spacing24),
|
? PlanFlowGraph(
|
||||||
onReorder: controller.reorderSection,
|
// Rebuild the graph when the plan structure changes.
|
||||||
footer: Center(
|
key: ValueKey(
|
||||||
child: Padding(
|
'${data.plan.id}-${data.plan.totalExercises}-'
|
||||||
padding: const EdgeInsets.symmetric(
|
'${data.plan.sections.length}',
|
||||||
vertical: UIConstants.spacing16,
|
),
|
||||||
),
|
plan: data.plan,
|
||||||
child: OutlinedButton.icon(
|
)
|
||||||
onPressed: controller.addSection,
|
: ReorderableListView.builder(
|
||||||
icon: const Icon(Icons.add, size: 16),
|
padding: const EdgeInsets.all(UIConstants.spacing24),
|
||||||
label: const Text('Add Section'),
|
onReorder: controller.reorderSection,
|
||||||
style: OutlinedButton.styleFrom(
|
footer: Center(
|
||||||
foregroundColor: AppColors.textSecondary,
|
child: Padding(
|
||||||
side: const BorderSide(color: AppColors.border),
|
padding: const EdgeInsets.symmetric(
|
||||||
padding: const EdgeInsets.symmetric(
|
vertical: UIConstants.spacing16,
|
||||||
horizontal: UIConstants.spacing24,
|
),
|
||||||
vertical: UIConstants.spacing12,
|
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(
|
error: (e, s) => Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Error: $e',
|
'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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
121
lib/presentation/plan_editor/widgets/plan_flow_graph.dart
Normal file
121
lib/presentation/plan_editor/widgets/plan_flow_graph.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:archive/archive_io.dart';
|
import 'package:archive/archive_io.dart';
|
||||||
@@ -27,12 +28,24 @@ Future<String> _llamaArchiveUrl() async {
|
|||||||
throw UnsupportedError('Unsupported platform: ${Platform.operatingSystem}');
|
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 {
|
class AiModelSettingsController extends _$AiModelSettingsController {
|
||||||
final _dio = Dio();
|
final _dio = Dio();
|
||||||
|
|
||||||
@override
|
@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 {
|
Future<void> validateModels() async {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
@@ -42,13 +55,19 @@ class AiModelSettingsController extends _$AiModelSettingsController {
|
|||||||
try {
|
try {
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
final base = dir.path;
|
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 =
|
final validated =
|
||||||
serverBin.existsSync() &&
|
_isComplete(
|
||||||
nomicModel.existsSync() &&
|
File(p.join(base, AiConstants.serverBinaryName)),
|
||||||
qwenModel.existsSync();
|
AiConstants.serverBinaryMinBytes,
|
||||||
|
) &&
|
||||||
|
_isComplete(
|
||||||
|
File(p.join(base, AiConstants.nomicModelFile)),
|
||||||
|
AiConstants.nomicModelMinBytes,
|
||||||
|
) &&
|
||||||
|
_isComplete(
|
||||||
|
File(p.join(base, AiConstants.qwenModelFile)),
|
||||||
|
AiConstants.qwenModelMinBytes,
|
||||||
|
);
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
areModelsValidated: validated,
|
areModelsValidated: validated,
|
||||||
currentTask: validated ? 'All files present.' : 'Files missing.',
|
currentTask: validated ? 'All files present.' : 'Files missing.',
|
||||||
@@ -97,14 +116,16 @@ class AiModelSettingsController extends _$AiModelSettingsController {
|
|||||||
savePath: p.join(dir.path, AiConstants.nomicModelFile),
|
savePath: p.join(dir.path, AiConstants.nomicModelFile),
|
||||||
taskLabel: 'Downloading Nomic embedding model…',
|
taskLabel: 'Downloading Nomic embedding model…',
|
||||||
overallStart: 0.2,
|
overallStart: 0.2,
|
||||||
overallEnd: 0.55,
|
overallEnd: 0.35,
|
||||||
|
minBytes: AiConstants.nomicModelMinBytes,
|
||||||
);
|
);
|
||||||
await _downloadFile(
|
await _downloadFile(
|
||||||
url: AiConstants.qwenModelUrl,
|
url: AiConstants.qwenModelUrl,
|
||||||
savePath: p.join(dir.path, AiConstants.qwenModelFile),
|
savePath: p.join(dir.path, AiConstants.qwenModelFile),
|
||||||
taskLabel: 'Downloading Qwen 2.5 7B model…',
|
taskLabel: 'Downloading Qwen3 4B model…',
|
||||||
overallStart: 0.55,
|
overallStart: 0.35,
|
||||||
overallEnd: 1.0,
|
overallEnd: 1.0,
|
||||||
|
minBytes: AiConstants.qwenModelMinBytes,
|
||||||
);
|
);
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
isDownloading: false,
|
isDownloading: false,
|
||||||
@@ -112,6 +133,11 @@ class AiModelSettingsController extends _$AiModelSettingsController {
|
|||||||
currentTask: 'Download complete.',
|
currentTask: 'Download complete.',
|
||||||
);
|
);
|
||||||
await validateModels();
|
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) {
|
} on DioException catch (e) {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
isDownloading: false,
|
isDownloading: false,
|
||||||
@@ -133,11 +159,23 @@ class AiModelSettingsController extends _$AiModelSettingsController {
|
|||||||
required String taskLabel,
|
required String taskLabel,
|
||||||
required double overallStart,
|
required double overallStart,
|
||||||
required double overallEnd,
|
required double overallEnd,
|
||||||
|
int minBytes = 0,
|
||||||
}) async {
|
}) 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);
|
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(
|
await _dio.download(
|
||||||
url,
|
url,
|
||||||
savePath,
|
partPath,
|
||||||
onReceiveProgress: (received, total) {
|
onReceiveProgress: (received, total) {
|
||||||
if (total <= 0) return;
|
if (total <= 0) return;
|
||||||
final fileProgress = received / total;
|
final fileProgress = received / total;
|
||||||
@@ -151,6 +189,10 @@ class AiModelSettingsController extends _$AiModelSettingsController {
|
|||||||
receiveTimeout: const Duration(hours: 2),
|
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 {
|
Future<void> _extractBinary(String archivePath, String destDir) async {
|
||||||
|
|||||||
@@ -7,15 +7,12 @@ part of 'ai_model_settings_controller.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$aiModelSettingsControllerHash() =>
|
String _$aiModelSettingsControllerHash() =>
|
||||||
r'27a37c3fafb21b93a8b5523718f1537419bd382a';
|
r'41d0842f57085bab80f0de18d8ab0eb72cdd5440';
|
||||||
|
|
||||||
/// See also [AiModelSettingsController].
|
/// See also [AiModelSettingsController].
|
||||||
@ProviderFor(AiModelSettingsController)
|
@ProviderFor(AiModelSettingsController)
|
||||||
final aiModelSettingsControllerProvider =
|
final aiModelSettingsControllerProvider =
|
||||||
AutoDisposeNotifierProvider<
|
NotifierProvider<AiModelSettingsController, AiModelSettingsState>.internal(
|
||||||
AiModelSettingsController,
|
|
||||||
AiModelSettingsState
|
|
||||||
>.internal(
|
|
||||||
AiModelSettingsController.new,
|
AiModelSettingsController.new,
|
||||||
name: r'aiModelSettingsControllerProvider',
|
name: r'aiModelSettingsControllerProvider',
|
||||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||||
@@ -25,6 +22,6 @@ final aiModelSettingsControllerProvider =
|
|||||||
allTransitiveDependencies: null,
|
allTransitiveDependencies: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
typedef _$AiModelSettingsController = AutoDisposeNotifier<AiModelSettingsState>;
|
typedef _$AiModelSettingsController = Notifier<AiModelSettingsState>;
|
||||||
// ignore_for_file: type=lint
|
// 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
|
// 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
|
||||||
|
|||||||
@@ -53,10 +53,7 @@ class KnowledgeBaseController extends _$KnowledgeBaseController {
|
|||||||
successMessage: 'Saved! Knowledge base now has $count chunks.',
|
successMessage: 'Saved! Knowledge base now has $count chunks.',
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state = state.copyWith(
|
state = state.copyWith(isLoading: false, errorMessage: _friendlyError(e));
|
||||||
isLoading: false,
|
|
||||||
errorMessage: _friendlyError(e),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,10 +75,7 @@ class KnowledgeBaseController extends _$KnowledgeBaseController {
|
|||||||
successMessage: 'Knowledge base cleared.',
|
successMessage: 'Knowledge base cleared.',
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state = state.copyWith(
|
state = state.copyWith(isLoading: false, errorMessage: _friendlyError(e));
|
||||||
isLoading: false,
|
|
||||||
errorMessage: _friendlyError(e),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,8 +85,7 @@ class KnowledgeBaseController extends _$KnowledgeBaseController {
|
|||||||
|
|
||||||
String _friendlyError(Object e) {
|
String _friendlyError(Object e) {
|
||||||
final msg = e.toString();
|
final msg = e.toString();
|
||||||
if (msg.contains('Connection refused') ||
|
if (msg.contains('Connection refused') || msg.contains('SocketException')) {
|
||||||
msg.contains('SocketException')) {
|
|
||||||
return 'Cannot reach the embedding server. '
|
return 'Cannot reach the embedding server. '
|
||||||
'Make sure AI models are downloaded and the app has had time to '
|
'Make sure AI models are downloaded and the app has had time to '
|
||||||
'start the inference servers.';
|
'start the inference servers.';
|
||||||
|
|||||||
@@ -91,8 +91,10 @@ class _KnowledgeBasePageState extends ConsumerState<KnowledgeBasePage> {
|
|||||||
final controller = ref.read(knowledgeBaseControllerProvider.notifier);
|
final controller = ref.read(knowledgeBaseControllerProvider.notifier);
|
||||||
|
|
||||||
// Show success SnackBar when a note is saved successfully.
|
// Show success SnackBar when a note is saved successfully.
|
||||||
ref.listen<KnowledgeBaseState>(knowledgeBaseControllerProvider,
|
ref.listen<KnowledgeBaseState>(knowledgeBaseControllerProvider, (
|
||||||
(prev, next) {
|
prev,
|
||||||
|
next,
|
||||||
|
) {
|
||||||
if (next.successMessage != null &&
|
if (next.successMessage != null &&
|
||||||
next.successMessage != prev?.successMessage) {
|
next.successMessage != prev?.successMessage) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -107,12 +109,10 @@ class _KnowledgeBasePageState extends ConsumerState<KnowledgeBasePage> {
|
|||||||
backgroundColor: AppColors.surfaceContainerHigh,
|
backgroundColor: AppColors.surfaceContainerHigh,
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius:
|
borderRadius: BorderRadius.circular(
|
||||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
UIConstants.smallBorderRadius,
|
||||||
side: const BorderSide(
|
|
||||||
color: AppColors.success,
|
|
||||||
width: 1,
|
|
||||||
),
|
),
|
||||||
|
side: const BorderSide(color: AppColors.success, width: 1),
|
||||||
),
|
),
|
||||||
duration: const Duration(seconds: 3),
|
duration: const Duration(seconds: 3),
|
||||||
),
|
),
|
||||||
@@ -139,12 +139,12 @@ class _KnowledgeBasePageState extends ConsumerState<KnowledgeBasePage> {
|
|||||||
children: [
|
children: [
|
||||||
// Heading
|
// Heading
|
||||||
Text(
|
Text(
|
||||||
'Knowledge Base',
|
'KNOWLEDGE BASE',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.chakraPetch(
|
||||||
fontSize: 22,
|
fontSize: 22,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
letterSpacing: -0.3,
|
letterSpacing: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: UIConstants.spacing8),
|
const SizedBox(height: UIConstants.spacing8),
|
||||||
@@ -229,9 +229,7 @@ class _KnowledgeBasePageState extends ConsumerState<KnowledgeBasePage> {
|
|||||||
),
|
),
|
||||||
if (kbState.chunkCount > 0) ...[
|
if (kbState.chunkCount > 0) ...[
|
||||||
const SizedBox(width: UIConstants.spacing12),
|
const SizedBox(width: UIConstants.spacing12),
|
||||||
_ClearButton(
|
_ClearButton(onPressed: () => _clear(controller)),
|
||||||
onPressed: () => _clear(controller),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -327,9 +325,9 @@ class _StatusCard extends StatelessWidget {
|
|||||||
child: Text(
|
child: Text(
|
||||||
hasChunks
|
hasChunks
|
||||||
? '$chunkCount chunk${chunkCount == 1 ? '' : 's'} stored — '
|
? '$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 '
|
: 'No notes added yet. The AI chat will use only its base '
|
||||||
'training knowledge.',
|
'training knowledge.',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: hasChunks ? AppColors.success : AppColors.textMuted,
|
color: hasChunks ? AppColors.success : AppColors.textMuted,
|
||||||
@@ -407,8 +405,7 @@ class _SaveButtonState extends State<_SaveButton> {
|
|||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius:
|
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
|
||||||
onTap: widget.onPressed,
|
onTap: widget.onPressed,
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -471,8 +468,7 @@ class _ClearButtonState extends State<_ClearButton> {
|
|||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius:
|
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
|
||||||
onTap: widget.onPressed,
|
onTap: widget.onPressed,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
@@ -484,8 +480,9 @@ class _ClearButtonState extends State<_ClearButton> {
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.delete_outline_rounded,
|
Icons.delete_outline_rounded,
|
||||||
size: 15,
|
size: 15,
|
||||||
color:
|
color: _hovered
|
||||||
_hovered ? AppColors.destructive : AppColors.textMuted,
|
? AppColors.destructive
|
||||||
|
: AppColors.textMuted,
|
||||||
),
|
),
|
||||||
const SizedBox(width: UIConstants.spacing8),
|
const SizedBox(width: UIConstants.spacing8),
|
||||||
Text(
|
Text(
|
||||||
@@ -523,9 +520,7 @@ class _ErrorBanner extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.destructiveMuted,
|
color: AppColors.destructiveMuted,
|
||||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||||
border: Border.all(
|
border: Border.all(color: AppColors.destructive.withValues(alpha: 0.4)),
|
||||||
color: AppColors.destructive.withValues(alpha: 0.4),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -589,7 +584,8 @@ class _HowItWorksCard extends StatelessWidget {
|
|||||||
const SizedBox(height: UIConstants.spacing12),
|
const SizedBox(height: UIConstants.spacing12),
|
||||||
_Step(
|
_Step(
|
||||||
n: '1',
|
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.',
|
'and sentence boundaries.',
|
||||||
),
|
),
|
||||||
const SizedBox(height: UIConstants.spacing8),
|
const SizedBox(height: UIConstants.spacing8),
|
||||||
|
|||||||
@@ -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/core/theme/app_colors.dart';
|
||||||
import 'package:trainhub_flutter/presentation/settings/ai_model_settings_controller.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_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/knowledge_base_section.dart';
|
||||||
import 'package:trainhub_flutter/presentation/settings/widgets/settings_top_bar.dart';
|
import 'package:trainhub_flutter/presentation/settings/widgets/settings_top_bar.dart';
|
||||||
|
|
||||||
@@ -34,15 +35,17 @@ class SettingsPage extends ConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Settings',
|
'SETTINGS',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.chakraPetch(
|
||||||
fontSize: 22,
|
fontSize: 22,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
letterSpacing: -0.3,
|
letterSpacing: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: UIConstants.spacing32),
|
const SizedBox(height: UIConstants.spacing32),
|
||||||
|
const AiProviderSection(),
|
||||||
|
const SizedBox(height: UIConstants.spacing32),
|
||||||
AiModelsSection(
|
AiModelsSection(
|
||||||
modelState: modelState,
|
modelState: modelState,
|
||||||
onDownload: controller.downloadAll,
|
onDownload: controller.downloadAll,
|
||||||
|
|||||||
@@ -49,13 +49,13 @@ class AiModelsSection extends StatelessWidget {
|
|||||||
const Divider(height: 1, color: AppColors.border),
|
const Divider(height: 1, color: AppColors.border),
|
||||||
const _ModelRow(
|
const _ModelRow(
|
||||||
name: 'Nomic Embed v1.5 Q4_K_M',
|
name: 'Nomic Embed v1.5 Q4_K_M',
|
||||||
description: 'Text embedding model (~300 MB)',
|
description: 'Text embedding model (~84 MB)',
|
||||||
icon: Icons.hub_outlined,
|
icon: Icons.hub_outlined,
|
||||||
),
|
),
|
||||||
const Divider(height: 1, color: AppColors.border),
|
const Divider(height: 1, color: AppColors.border),
|
||||||
const _ModelRow(
|
const _ModelRow(
|
||||||
name: 'Qwen 2.5 7B Instruct Q4_K_M',
|
name: 'Qwen3 4B Instruct 2507 Q4_K_M',
|
||||||
description: 'Chat / reasoning model (~4.7 GB)',
|
description: 'Chat / reasoning model (~2.4 GB)',
|
||||||
icon: Icons.psychology_outlined,
|
icon: Icons.psychology_outlined,
|
||||||
),
|
),
|
||||||
const Divider(height: 1, color: AppColors.border),
|
const Divider(height: 1, color: AppColors.border),
|
||||||
@@ -162,7 +162,7 @@ class _StatusAndActions extends StatelessWidget {
|
|||||||
const SizedBox(height: UIConstants.spacing16),
|
const SizedBox(height: UIConstants.spacing16),
|
||||||
if (!modelState.areModelsValidated)
|
if (!modelState.areModelsValidated)
|
||||||
SettingsActionButton(
|
SettingsActionButton(
|
||||||
label: 'Download AI Models (~5 GB)',
|
label: 'Download AI Models (~2.7 GB)',
|
||||||
icon: Icons.download_rounded,
|
icon: Icons.download_rounded,
|
||||||
color: AppColors.accent,
|
color: AppColors.accent,
|
||||||
textColor: AppColors.zinc950,
|
textColor: AppColors.zinc950,
|
||||||
@@ -190,11 +190,13 @@ class _StatusBadge extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final color = validated ? AppColors.success : AppColors.textMuted;
|
final color = validated ? AppColors.success : AppColors.textMuted;
|
||||||
final bgColor =
|
final bgColor = validated
|
||||||
validated ? AppColors.successMuted : AppColors.surfaceContainerHigh;
|
? AppColors.successMuted
|
||||||
|
: AppColors.surfaceContainerHigh;
|
||||||
final label = validated ? 'Ready' : 'Missing';
|
final label = validated ? 'Ready' : 'Missing';
|
||||||
final icon =
|
final icon = validated
|
||||||
validated ? Icons.check_circle_outline : Icons.radio_button_unchecked;
|
? Icons.check_circle_outline
|
||||||
|
: Icons.radio_button_unchecked;
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -277,8 +279,7 @@ class _DownloadingView extends StatelessWidget {
|
|||||||
value: modelState.progress,
|
value: modelState.progress,
|
||||||
minHeight: 6,
|
minHeight: 6,
|
||||||
backgroundColor: AppColors.zinc800,
|
backgroundColor: AppColors.zinc800,
|
||||||
valueColor:
|
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.accent),
|
||||||
const AlwaysStoppedAnimation<Color>(AppColors.accent),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (modelState.errorMessage != null) ...[
|
if (modelState.errorMessage != null) ...[
|
||||||
|
|||||||
242
lib/presentation/settings/widgets/ai_provider_section.dart
Normal file
242
lib/presentation/settings/widgets/ai_provider_section.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,16 +42,15 @@ class _SettingsActionButtonState extends State<SettingsActionButton> {
|
|||||||
color: hasBorder
|
color: hasBorder
|
||||||
? (_hovered ? AppColors.zinc800 : Colors.transparent)
|
? (_hovered ? AppColors.zinc800 : Colors.transparent)
|
||||||
: (_hovered
|
: (_hovered
|
||||||
? widget.color.withValues(alpha: 0.85)
|
? widget.color.withValues(alpha: 0.85)
|
||||||
: widget.color),
|
: widget.color),
|
||||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||||
border: hasBorder ? Border.all(color: widget.borderColor!) : null,
|
border: hasBorder ? Border.all(color: widget.borderColor!) : null,
|
||||||
),
|
),
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius:
|
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
|
||||||
onTap: widget.onPressed,
|
onTap: widget.onPressed,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
|
|||||||
@@ -78,8 +78,7 @@ class _SettingsIconButtonState extends State<SettingsIconButton> {
|
|||||||
child: Icon(
|
child: Icon(
|
||||||
widget.icon,
|
widget.icon,
|
||||||
size: 18,
|
size: 18,
|
||||||
color:
|
color: _hovered ? AppColors.textPrimary : AppColors.textSecondary,
|
||||||
_hovered ? AppColors.textPrimary : AppColors.textSecondary,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:trainhub_flutter/core/constants/app_constants.dart';
|
import 'package:trainhub_flutter/core/constants/app_constants.dart';
|
||||||
import 'package:trainhub_flutter/core/constants/ui_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/router/app_router.dart';
|
||||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||||
|
import 'package:trainhub_flutter/presentation/settings/ai_model_settings_controller.dart';
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
class ShellPage extends StatelessWidget {
|
class ShellPage extends StatelessWidget {
|
||||||
@@ -113,21 +115,22 @@ class _Sidebar extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.accentMuted,
|
color: AppColors.accentMuted,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: AppColors.accentBorder),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.fitness_center,
|
Icons.sports_martial_arts,
|
||||||
color: AppColors.accent,
|
color: AppColors.accent,
|
||||||
size: 18,
|
size: 18,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Text(
|
Text(
|
||||||
'TrainHub',
|
'TRAINHUB',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.chakraPetch(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
letterSpacing: -0.3,
|
letterSpacing: 1.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -150,6 +153,9 @@ class _Sidebar extends StatelessWidget {
|
|||||||
|
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
|
|
||||||
|
// --- AI model download progress (runs in the background) ---
|
||||||
|
const _DownloadIndicator(),
|
||||||
|
|
||||||
// --- Footer ---
|
// --- Footer ---
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
|
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),
|
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: active
|
color: active
|
||||||
? AppColors.zinc800
|
? AppColors.accentMuted
|
||||||
: _isHovered
|
: _isHovered
|
||||||
? AppColors.zinc900
|
? AppColors.surfaceContainerHigh
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -229,19 +235,20 @@ class _NavItemState extends State<_NavItem> {
|
|||||||
Icon(
|
Icon(
|
||||||
active ? widget.data.activeIcon : widget.data.icon,
|
active ? widget.data.activeIcon : widget.data.icon,
|
||||||
size: 17,
|
size: 17,
|
||||||
color: active ? AppColors.textPrimary : AppColors.textMuted,
|
color: active ? AppColors.accent : AppColors.textMuted,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 9),
|
const SizedBox(width: 9),
|
||||||
Text(
|
Text(
|
||||||
widget.data.label,
|
widget.data.label.toUpperCase(),
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.chakraPetch(
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
fontWeight: active ? FontWeight.w600 : FontWeight.w400,
|
fontWeight: active ? FontWeight.w600 : FontWeight.w500,
|
||||||
|
letterSpacing: 1.2,
|
||||||
color: active
|
color: active
|
||||||
? AppColors.textPrimary
|
? AppColors.textPrimary
|
||||||
: _isHovered
|
: _isHovered
|
||||||
? AppColors.textSecondary
|
? AppColors.textSecondary
|
||||||
: AppColors.textMuted,
|
: 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
|
// Settings icon button in sidebar footer
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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/widgets/app_empty_state.dart';
|
||||||
import 'package:trainhub_flutter/presentation/common/dialogs/text_input_dialog.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/common/dialogs/confirm_dialog.dart';
|
||||||
|
import 'package:trainhub_flutter/presentation/trainings/widgets/combo_builder_tab.dart';
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
class TrainingsPage extends ConsumerWidget {
|
class TrainingsPage extends ConsumerWidget {
|
||||||
@@ -24,7 +25,7 @@ class TrainingsPage extends ConsumerWidget {
|
|||||||
final asyncState = ref.watch(trainingsControllerProvider);
|
final asyncState = ref.watch(trainingsControllerProvider);
|
||||||
|
|
||||||
return DefaultTabController(
|
return DefaultTabController(
|
||||||
length: 2,
|
length: 3,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
@@ -34,8 +35,9 @@ class TrainingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
child: const TabBar(
|
child: const TabBar(
|
||||||
tabs: [
|
tabs: [
|
||||||
Tab(text: 'Training Plans'),
|
Tab(text: 'TRAINING PLANS'),
|
||||||
Tab(text: 'Exercises'),
|
Tab(text: 'EXERCISES'),
|
||||||
|
Tab(text: 'COMBOS'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -47,6 +49,7 @@ class TrainingsPage extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
_PlansTab(plans: state.plans, ref: ref),
|
_PlansTab(plans: state.plans, ref: ref),
|
||||||
_ExercisesTab(exercises: state.exercises, ref: ref),
|
_ExercisesTab(exercises: state.exercises, ref: ref),
|
||||||
|
const ComboBuilderTab(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -401,7 +404,8 @@ class _ExerciseListItemState extends State<_ExerciseListItem> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final hasVideo = widget.exercise.videoUrl != null &&
|
final hasVideo =
|
||||||
|
widget.exercise.videoUrl != null &&
|
||||||
widget.exercise.videoUrl!.isNotEmpty;
|
widget.exercise.videoUrl!.isNotEmpty;
|
||||||
|
|
||||||
return MouseRegion(
|
return MouseRegion(
|
||||||
@@ -542,8 +546,7 @@ class _ExercisePreviewDialog extends StatelessWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
if (hasVideo)
|
if (hasVideo) _ExerciseVideoPreview(videoPath: exercise.videoUrl!),
|
||||||
_ExerciseVideoPreview(videoPath: exercise.videoUrl!),
|
|
||||||
Flexible(
|
Flexible(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(UIConstants.spacing24),
|
padding: const EdgeInsets.all(UIConstants.spacing24),
|
||||||
@@ -578,8 +581,7 @@ class _ExercisePreviewDialog extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (exercise.tags != null &&
|
if (exercise.tags != null && exercise.tags!.isNotEmpty) ...[
|
||||||
exercise.tags!.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: UIConstants.spacing12),
|
const SizedBox(height: UIConstants.spacing12),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 6,
|
spacing: 6,
|
||||||
@@ -740,8 +742,8 @@ class _ExerciseVideoPreviewState extends State<_ExerciseVideoPreview> {
|
|||||||
_player
|
_player
|
||||||
.seek(Duration(milliseconds: (_clipStart * 1000).round()))
|
.seek(Duration(milliseconds: (_clipStart * 1000).round()))
|
||||||
.then((_) {
|
.then((_) {
|
||||||
if (mounted) setState(() => _isInitialized = true);
|
if (mounted) setState(() => _isInitialized = true);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if (mounted) setState(() => _isInitialized = true);
|
if (mounted) setState(() => _isInitialized = true);
|
||||||
}
|
}
|
||||||
@@ -827,7 +829,9 @@ class _ExerciseVideoPreviewState extends State<_ExerciseVideoPreview> {
|
|||||||
final hasClip = _clipEnd != double.infinity;
|
final hasClip = _clipEnd != double.infinity;
|
||||||
final clipDuration = hasClip ? (_clipEnd - _clipStart) : 0.0;
|
final clipDuration = hasClip ? (_clipEnd - _clipStart) : 0.0;
|
||||||
final clipPosition = (_position - _clipStart).clamp(0.0, clipDuration);
|
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(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|||||||
805
lib/presentation/trainings/widgets/combo_builder_tab.dart
Normal file
805
lib/presentation/trainings/widgets/combo_builder_tab.dart
Normal 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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,12 +28,12 @@ class _WelcomeScreenState extends ConsumerState<WelcomeScreen> {
|
|||||||
.read(aiModelSettingsControllerProvider.notifier)
|
.read(aiModelSettingsControllerProvider.notifier)
|
||||||
.validateModels()
|
.validateModels()
|
||||||
.then((_) {
|
.then((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final validated = ref
|
final validated = ref
|
||||||
.read(aiModelSettingsControllerProvider)
|
.read(aiModelSettingsControllerProvider)
|
||||||
.areModelsValidated;
|
.areModelsValidated;
|
||||||
if (validated) _navigateToApp();
|
if (validated) _navigateToApp();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,11 +47,11 @@ class _WelcomeScreenState extends ConsumerState<WelcomeScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final modelState = ref.watch(aiModelSettingsControllerProvider);
|
final modelState = ref.watch(aiModelSettingsControllerProvider);
|
||||||
|
|
||||||
ref.listen<AiModelSettingsState>(aiModelSettingsControllerProvider,
|
ref.listen<AiModelSettingsState>(aiModelSettingsControllerProvider, (
|
||||||
(prev, next) {
|
prev,
|
||||||
if (!_hasNavigated &&
|
next,
|
||||||
next.areModelsValidated &&
|
) {
|
||||||
!next.isDownloading) {
|
if (!_hasNavigated && next.areModelsValidated && !next.isDownloading) {
|
||||||
_navigateToApp();
|
_navigateToApp();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,12 +32,12 @@ class DownloadProgress extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: UIConstants.spacing24),
|
const SizedBox(height: UIConstants.spacing24),
|
||||||
Text(
|
Text(
|
||||||
'Setting up AI models\u2026',
|
'SETTING UP AI MODELS\u2026',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.chakraPetch(
|
||||||
fontSize: 22,
|
fontSize: 22,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
letterSpacing: -0.3,
|
letterSpacing: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: UIConstants.spacing8),
|
const SizedBox(height: UIConstants.spacing8),
|
||||||
@@ -55,8 +55,7 @@ class DownloadProgress extends StatelessWidget {
|
|||||||
value: modelState.progress,
|
value: modelState.progress,
|
||||||
minHeight: 6,
|
minHeight: 6,
|
||||||
backgroundColor: AppColors.zinc800,
|
backgroundColor: AppColors.zinc800,
|
||||||
valueColor:
|
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.accent),
|
||||||
const AlwaysStoppedAnimation<Color>(AppColors.accent),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: UIConstants.spacing12),
|
const SizedBox(height: UIConstants.spacing12),
|
||||||
@@ -105,9 +104,7 @@ class ErrorBanner extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.destructiveMuted,
|
color: AppColors.destructiveMuted,
|
||||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||||
border: Border.all(
|
border: Border.all(color: AppColors.destructive.withValues(alpha: 0.4)),
|
||||||
color: AppColors.destructive.withValues(alpha: 0.4),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|||||||
@@ -46,19 +46,19 @@ class InitialPrompt extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.fitness_center,
|
Icons.sports_martial_arts,
|
||||||
color: AppColors.accent,
|
color: AppColors.accent,
|
||||||
size: 24,
|
size: 24,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: UIConstants.spacing12),
|
const SizedBox(width: UIConstants.spacing12),
|
||||||
Text(
|
Text(
|
||||||
'TrainHub',
|
'TRAINHUB',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.chakraPetch(
|
||||||
fontSize: 26,
|
fontSize: 26,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
letterSpacing: -0.5,
|
letterSpacing: 2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -67,13 +67,13 @@ class InitialPrompt extends StatelessWidget {
|
|||||||
|
|
||||||
Widget _buildHeadline() {
|
Widget _buildHeadline() {
|
||||||
return Text(
|
return Text(
|
||||||
'AI-powered coaching,\nright on your device.',
|
'AI-POWERED COACHING,\nRIGHT ON YOUR DEVICE.',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.chakraPetch(
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
height: 1.25,
|
height: 1.25,
|
||||||
letterSpacing: -0.5,
|
letterSpacing: 0.5,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -101,7 +101,7 @@ class InitialPrompt extends StatelessWidget {
|
|||||||
SizedBox(height: UIConstants.spacing12),
|
SizedBox(height: UIConstants.spacing12),
|
||||||
FeatureRow(
|
FeatureRow(
|
||||||
icon: Icons.psychology_outlined,
|
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),
|
SizedBox(height: UIConstants.spacing12),
|
||||||
FeatureRow(
|
FeatureRow(
|
||||||
@@ -121,9 +121,7 @@ class InitialPrompt extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.accentMuted,
|
color: AppColors.accentMuted,
|
||||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||||
border: Border.all(
|
border: Border.all(color: AppColors.accent.withValues(alpha: 0.3)),
|
||||||
color: AppColors.accent.withValues(alpha: 0.3),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -135,7 +133,7 @@ class InitialPrompt extends StatelessWidget {
|
|||||||
const SizedBox(width: UIConstants.spacing12),
|
const SizedBox(width: UIConstants.spacing12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
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.',
|
'You can skip now and download later from Settings.',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
|||||||
@@ -39,8 +39,7 @@ class _WelcomePrimaryButtonState extends State<WelcomePrimaryButton> {
|
|||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius:
|
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
|
||||||
onTap: widget.onPressed,
|
onTap: widget.onPressed,
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -75,8 +74,7 @@ class WelcomeSecondaryButton extends StatefulWidget {
|
|||||||
final VoidCallback onPressed;
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<WelcomeSecondaryButton> createState() =>
|
State<WelcomeSecondaryButton> createState() => _WelcomeSecondaryButtonState();
|
||||||
_WelcomeSecondaryButtonState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _WelcomeSecondaryButtonState extends State<WelcomeSecondaryButton> {
|
class _WelcomeSecondaryButtonState extends State<WelcomeSecondaryButton> {
|
||||||
@@ -98,8 +96,7 @@ class _WelcomeSecondaryButtonState extends State<WelcomeSecondaryButton> {
|
|||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius:
|
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
|
||||||
onTap: widget.onPressed,
|
onTap: widget.onPressed,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
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/theme/app_colors.dart';
|
||||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||||
import 'package:trainhub_flutter/domain/entities/workout_activity.dart';
|
import 'package:trainhub_flutter/domain/entities/workout_activity.dart';
|
||||||
@@ -21,9 +22,7 @@ class ActivityCard extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.surfaceContainer.withValues(alpha: 0.6),
|
color: AppColors.surfaceContainer.withValues(alpha: 0.6),
|
||||||
borderRadius: UIConstants.cardBorderRadius,
|
borderRadius: UIConstants.cardBorderRadius,
|
||||||
border: Border.all(
|
border: Border.all(color: accentColor.withValues(alpha: 0.2)),
|
||||||
color: accentColor.withValues(alpha: 0.2),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -47,11 +46,12 @@ class ActivityCard extends StatelessWidget {
|
|||||||
const SizedBox(height: UIConstants.spacing12),
|
const SizedBox(height: UIConstants.spacing12),
|
||||||
// Activity name
|
// Activity name
|
||||||
Text(
|
Text(
|
||||||
activity.name,
|
activity.name.toUpperCase(),
|
||||||
style: const TextStyle(
|
style: GoogleFonts.chakraPetch(
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
fontSize: 22,
|
fontSize: 22,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.5,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -59,10 +59,7 @@ class ActivityCard extends StatelessWidget {
|
|||||||
const SizedBox(height: UIConstants.spacing8),
|
const SizedBox(height: UIConstants.spacing8),
|
||||||
Text(
|
Text(
|
||||||
'${activity.sectionName ?? ''} \u00B7 Set ${activity.setIndex}/${activity.totalSets}',
|
'${activity.sectionName ?? ''} \u00B7 Set ${activity.setIndex}/${activity.totalSets}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(color: AppColors.textMuted, fontSize: 13),
|
||||||
color: AppColors.textMuted,
|
|
||||||
fontSize: 13,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (activity.originalExercise != null) ...[
|
if (activity.originalExercise != null) ...[
|
||||||
const SizedBox(height: UIConstants.spacing16),
|
const SizedBox(height: UIConstants.spacing16),
|
||||||
@@ -86,10 +83,7 @@ class ActivityCard extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.only(top: UIConstants.spacing8),
|
padding: const EdgeInsets.only(top: UIConstants.spacing8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Take a break',
|
'Take a break',
|
||||||
style: TextStyle(
|
style: TextStyle(color: AppColors.textMuted, fontSize: 14),
|
||||||
color: AppColors.textMuted,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -124,10 +118,7 @@ class _InfoChip extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: const TextStyle(
|
style: const TextStyle(color: AppColors.textMuted, fontSize: 11),
|
||||||
color: AppColors.textMuted,
|
|
||||||
fontSize: 11,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -46,11 +46,7 @@ class SessionControls extends StatelessWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (isTimeBased) ...[
|
if (isTimeBased) ...[
|
||||||
_ControlButton(
|
_ControlButton(icon: Icons.replay_10, onTap: onRewind, size: 24),
|
||||||
icon: Icons.replay_10,
|
|
||||||
onTap: onRewind,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
const SizedBox(width: UIConstants.spacing8),
|
const SizedBox(width: UIConstants.spacing8),
|
||||||
],
|
],
|
||||||
_ControlButton(
|
_ControlButton(
|
||||||
|
|||||||
@@ -101,9 +101,7 @@ class WorkoutSessionController extends _$WorkoutSessionController {
|
|||||||
0,
|
0,
|
||||||
maxDuration,
|
maxDuration,
|
||||||
);
|
);
|
||||||
state = AsyncValue.data(
|
state = AsyncValue.data(currentState.copyWith(timeRemaining: newRemaining));
|
||||||
currentState.copyWith(timeRemaining: newRemaining),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _tick(Timer timer) {
|
void _tick(Timer timer) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:math' as math;
|
|||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/constants/ui_constants.dart';
|
||||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||||
import 'package:trainhub_flutter/domain/entities/workout_activity.dart';
|
import 'package:trainhub_flutter/domain/entities/workout_activity.dart';
|
||||||
@@ -25,7 +26,7 @@ class WorkoutSessionPage extends ConsumerWidget {
|
|||||||
final asyncState = ref.watch(workoutSessionControllerProvider(planId));
|
final asyncState = ref.watch(workoutSessionControllerProvider(planId));
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.zinc950,
|
backgroundColor: AppColors.surface,
|
||||||
body: asyncState.when(
|
body: asyncState.when(
|
||||||
loading: () => const Center(
|
loading: () => const Center(
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
@@ -71,15 +72,10 @@ class WorkoutSessionPage extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (state.isFinished) {
|
if (state.isFinished) {
|
||||||
return _CompletionScreen(
|
return _CompletionScreen(totalTimeElapsed: state.totalTimeElapsed);
|
||||||
totalTimeElapsed: state.totalTimeElapsed,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return _ActiveSessionView(
|
return _ActiveSessionView(state: state, controller: controller);
|
||||||
state: state,
|
|
||||||
controller: controller,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -90,10 +86,7 @@ class _ActiveSessionView extends StatefulWidget {
|
|||||||
final WorkoutSessionState state;
|
final WorkoutSessionState state;
|
||||||
final WorkoutSessionController controller;
|
final WorkoutSessionController controller;
|
||||||
|
|
||||||
const _ActiveSessionView({
|
const _ActiveSessionView({required this.state, required this.controller});
|
||||||
required this.state,
|
|
||||||
required this.controller,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_ActiveSessionView> createState() => _ActiveSessionViewState();
|
State<_ActiveSessionView> createState() => _ActiveSessionViewState();
|
||||||
@@ -110,8 +103,7 @@ class _ActiveSessionViewState extends State<_ActiveSessionView> {
|
|||||||
|
|
||||||
final double timeProgress;
|
final double timeProgress;
|
||||||
if (activity != null && activity.duration > 0) {
|
if (activity != null && activity.duration > 0) {
|
||||||
timeProgress =
|
timeProgress = 1.0 - (widget.state.timeRemaining / activity.duration);
|
||||||
1.0 - (widget.state.timeRemaining / activity.duration);
|
|
||||||
} else {
|
} else {
|
||||||
timeProgress = 0.0;
|
timeProgress = 0.0;
|
||||||
}
|
}
|
||||||
@@ -128,11 +120,7 @@ class _ActiveSessionViewState extends State<_ActiveSessionView> {
|
|||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [
|
colors: [AppColors.surface, accentTint, AppColors.surface],
|
||||||
AppColors.zinc950,
|
|
||||||
accentTint,
|
|
||||||
AppColors.zinc950,
|
|
||||||
],
|
|
||||||
stops: const [0.0, 0.5, 1.0],
|
stops: const [0.0, 0.5, 1.0],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -149,8 +137,7 @@ class _ActiveSessionViewState extends State<_ActiveSessionView> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (activity != null)
|
if (activity != null) ActivityCard(activity: activity),
|
||||||
ActivityCard(activity: activity),
|
|
||||||
const SizedBox(height: UIConstants.spacing32),
|
const SizedBox(height: UIConstants.spacing32),
|
||||||
if (isTimeBased)
|
if (isTimeBased)
|
||||||
_CircularTimerDisplay(
|
_CircularTimerDisplay(
|
||||||
@@ -249,26 +236,20 @@ class _ActiveSessionViewState extends State<_ActiveSessionView> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.zinc800.withValues(alpha: 0.6),
|
color: AppColors.zinc800.withValues(alpha: 0.6),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(
|
border: Border.all(color: AppColors.border.withValues(alpha: 0.5)),
|
||||||
color: AppColors.border.withValues(alpha: 0.5),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(Icons.timer_outlined, size: 14, color: AppColors.textMuted),
|
||||||
Icons.timer_outlined,
|
|
||||||
size: 14,
|
|
||||||
color: AppColors.textMuted,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
_formatDuration(widget.state.totalTimeElapsed),
|
_formatDuration(widget.state.totalTimeElapsed),
|
||||||
style: const TextStyle(
|
style: GoogleFonts.jetBrainsMono(
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
fontFeatures: [FontFeature.tabularFigures()],
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -352,11 +333,10 @@ class _RepsDisplay extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'$reps',
|
'$reps',
|
||||||
style: TextStyle(
|
style: GoogleFonts.jetBrainsMono(
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
fontSize: 72,
|
fontSize: 72,
|
||||||
fontWeight: FontWeight.w200,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: -2,
|
|
||||||
shadows: [
|
shadows: [
|
||||||
Shadow(
|
Shadow(
|
||||||
color: AppColors.accent.withValues(alpha: 0.3),
|
color: AppColors.accent.withValues(alpha: 0.3),
|
||||||
@@ -462,13 +442,11 @@ class _CircularTimerDisplay extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
_formatTime(timeRemaining),
|
_formatTime(timeRemaining),
|
||||||
style: TextStyle(
|
style: GoogleFonts.jetBrainsMono(
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
fontSize: 64,
|
fontSize: 64,
|
||||||
fontWeight: FontWeight.w200,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: -1,
|
|
||||||
fontFeatures: const [FontFeature.tabularFigures()],
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
fontFamily: 'monospace',
|
|
||||||
shadows: [
|
shadows: [
|
||||||
Shadow(
|
Shadow(
|
||||||
color: ringColor.withValues(alpha: 0.3),
|
color: ringColor.withValues(alpha: 0.3),
|
||||||
@@ -525,10 +503,7 @@ class TimerRingPainter extends CustomPainter {
|
|||||||
final double progress;
|
final double progress;
|
||||||
final Color ringColor;
|
final Color ringColor;
|
||||||
|
|
||||||
TimerRingPainter({
|
TimerRingPainter({required this.progress, required this.ringColor});
|
||||||
required this.progress,
|
|
||||||
required this.ringColor,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
@@ -551,10 +526,10 @@ class TimerRingPainter extends CustomPainter {
|
|||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeWidth = strokeWidth * 2
|
..strokeWidth = strokeWidth * 2
|
||||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 16);
|
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 16);
|
||||||
|
|
||||||
final sweepAngle = 2 * math.pi * progress;
|
final sweepAngle = 2 * math.pi * progress;
|
||||||
final startAngle = -math.pi / 2;
|
final startAngle = -math.pi / 2;
|
||||||
|
|
||||||
canvas.drawArc(
|
canvas.drawArc(
|
||||||
Rect.fromCircle(center: center, radius: radius),
|
Rect.fromCircle(center: center, radius: radius),
|
||||||
startAngle,
|
startAngle,
|
||||||
@@ -576,7 +551,9 @@ class TimerRingPainter extends CustomPainter {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final progressPaint = Paint()
|
final progressPaint = Paint()
|
||||||
..shader = gradient.createShader(Rect.fromCircle(center: center, radius: radius))
|
..shader = gradient.createShader(
|
||||||
|
Rect.fromCircle(center: center, radius: radius),
|
||||||
|
)
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeCap = StrokeCap.round
|
..strokeCap = StrokeCap.round
|
||||||
..strokeWidth = strokeWidth;
|
..strokeWidth = strokeWidth;
|
||||||
@@ -593,7 +570,7 @@ class TimerRingPainter extends CustomPainter {
|
|||||||
@override
|
@override
|
||||||
bool shouldRepaint(TimerRingPainter oldDelegate) {
|
bool shouldRepaint(TimerRingPainter oldDelegate) {
|
||||||
return oldDelegate.progress != progress ||
|
return oldDelegate.progress != progress ||
|
||||||
oldDelegate.ringColor != ringColor;
|
oldDelegate.ringColor != ringColor;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,10 +578,7 @@ class _UpNextPill extends StatelessWidget {
|
|||||||
final String nextActivityName;
|
final String nextActivityName;
|
||||||
final bool isNextRest;
|
final bool isNextRest;
|
||||||
|
|
||||||
const _UpNextPill({
|
const _UpNextPill({required this.nextActivityName, required this.isNextRest});
|
||||||
required this.nextActivityName,
|
|
||||||
required this.isNextRest,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -693,9 +667,7 @@ class _ActivitiesListPanel extends StatelessWidget {
|
|||||||
width: 320,
|
width: 320,
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: AppColors.surfaceContainer,
|
color: AppColors.surfaceContainer,
|
||||||
border: Border(
|
border: Border(left: BorderSide(color: AppColors.border)),
|
||||||
left: BorderSide(color: AppColors.border),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -763,8 +735,8 @@ class _ActivitiesListPanel extends StatelessWidget {
|
|||||||
color: isCurrent
|
color: isCurrent
|
||||||
? AppColors.accent
|
? AppColors.accent
|
||||||
: isRest
|
: isRest
|
||||||
? AppColors.info
|
? AppColors.info
|
||||||
: AppColors.zinc600,
|
: AppColors.zinc600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: UIConstants.spacing12),
|
const SizedBox(width: UIConstants.spacing12),
|
||||||
@@ -785,7 +757,8 @@ class _ActivitiesListPanel extends StatelessWidget {
|
|||||||
: FontWeight.normal,
|
: FontWeight.normal,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!isRest && activity.setIndex != null)
|
if (!isRest &&
|
||||||
|
activity.setIndex != null)
|
||||||
Text(
|
Text(
|
||||||
'Set ${activity.setIndex}/${activity.totalSets} · ${activity.sectionName ?? ''}',
|
'Set ${activity.setIndex}/${activity.totalSets} · ${activity.sectionName ?? ''}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
@@ -888,22 +861,19 @@ class _CompletionScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: UIConstants.spacing24),
|
const SizedBox(height: UIConstants.spacing24),
|
||||||
const Text(
|
Text(
|
||||||
'Workout Complete',
|
'WORKOUT COMPLETE',
|
||||||
style: TextStyle(
|
style: GoogleFonts.chakraPetch(
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: -0.5,
|
letterSpacing: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: UIConstants.spacing8),
|
const SizedBox(height: UIConstants.spacing8),
|
||||||
Text(
|
Text(
|
||||||
'Great job! You crushed it.',
|
'Great job! You crushed it.',
|
||||||
style: TextStyle(
|
style: TextStyle(color: AppColors.textSecondary, fontSize: 15),
|
||||||
color: AppColors.textSecondary,
|
|
||||||
fontSize: 15,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: UIConstants.spacing32),
|
const SizedBox(height: UIConstants.spacing32),
|
||||||
Container(
|
Container(
|
||||||
@@ -932,12 +902,11 @@ class _CompletionScreen extends StatelessWidget {
|
|||||||
const SizedBox(height: UIConstants.spacing4),
|
const SizedBox(height: UIConstants.spacing4),
|
||||||
Text(
|
Text(
|
||||||
_formatDuration(totalTimeElapsed),
|
_formatDuration(totalTimeElapsed),
|
||||||
style: TextStyle(
|
style: GoogleFonts.jetBrainsMono(
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
fontSize: 36,
|
fontSize: 36,
|
||||||
fontWeight: FontWeight.w300,
|
fontWeight: FontWeight.w600,
|
||||||
fontFeatures: const [FontFeature.tabularFigures()],
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
fontFamily: 'monospace',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ class WorkoutSessionState with _$WorkoutSessionState {
|
|||||||
|
|
||||||
WorkoutActivityEntity? get nextActivity =>
|
WorkoutActivityEntity? get nextActivity =>
|
||||||
currentIndex + 1 < activities.length
|
currentIndex + 1 < activities.length
|
||||||
? activities[currentIndex + 1]
|
? activities[currentIndex + 1]
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
double get progress =>
|
double get progress =>
|
||||||
activities.isEmpty ? 0.0 : currentIndex / activities.length;
|
activities.isEmpty ? 0.0 : currentIndex / activities.length;
|
||||||
|
|||||||
20
pubspec.lock
20
pubspec.lock
@@ -149,10 +149,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.1"
|
||||||
charcode:
|
charcode:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -617,18 +617,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.17"
|
version: "0.12.19"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.11.1"
|
version: "0.13.0"
|
||||||
media_kit:
|
media_kit:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -697,10 +697,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
version: "1.18.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1070,10 +1070,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.7"
|
version: "0.7.11"
|
||||||
timing:
|
timing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: trainhub_flutter
|
name: trainhub_flutter
|
||||||
description: "TrainHub - Training Program Management"
|
description: "TrainHub - Training Program Management"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 2.0.0+1
|
version: 2.1.0+2
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.7
|
sdk: ^3.10.7
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 10 KiB |
Reference in New Issue
Block a user