import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:trainhub_flutter/core/constants/ui_constants.dart'; import 'package:trainhub_flutter/core/theme/app_colors.dart'; import 'package:trainhub_flutter/presentation/settings/knowledge_base_controller.dart'; import 'package:trainhub_flutter/presentation/settings/knowledge_base_state.dart'; @RoutePage() class KnowledgeBasePage extends ConsumerStatefulWidget { const KnowledgeBasePage({super.key}); @override ConsumerState createState() => _KnowledgeBasePageState(); } class _KnowledgeBasePageState extends ConsumerState { final _textController = TextEditingController(); @override void dispose() { _textController.dispose(); super.dispose(); } Future _save(KnowledgeBaseController controller) async { await controller.saveNote(_textController.text); // Only clear the field if save succeeded (no error in state). if (!mounted) return; final s = ref.read(knowledgeBaseControllerProvider); if (s.successMessage != null) _textController.clear(); } Future _clear(KnowledgeBaseController controller) async { final confirmed = await _showConfirmDialog(); if (!confirmed) return; await controller.clearKnowledgeBase(); } Future _showConfirmDialog() async { return await showDialog( context: context, builder: (ctx) => AlertDialog( backgroundColor: AppColors.surfaceContainer, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(UIConstants.borderRadius), side: const BorderSide(color: AppColors.border), ), title: Text( 'Clear knowledge base?', style: GoogleFonts.inter( color: AppColors.textPrimary, fontWeight: FontWeight.w600, fontSize: 15, ), ), content: Text( 'This will permanently delete all stored chunks and embeddings. ' 'This action cannot be undone.', style: GoogleFonts.inter( color: AppColors.textSecondary, fontSize: 13, height: 1.5, ), ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), child: Text( 'Cancel', style: GoogleFonts.inter(color: AppColors.textMuted), ), ), TextButton( onPressed: () => Navigator.of(ctx).pop(true), child: Text( 'Clear', style: GoogleFonts.inter(color: AppColors.destructive), ), ), ], ), ) ?? false; } @override Widget build(BuildContext context) { final kbState = ref.watch(knowledgeBaseControllerProvider); final controller = ref.read(knowledgeBaseControllerProvider.notifier); // Show success SnackBar when a note is saved successfully. ref.listen(knowledgeBaseControllerProvider, (prev, next) { if (next.successMessage != null && next.successMessage != prev?.successMessage) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( next.successMessage!, style: GoogleFonts.inter( color: AppColors.textPrimary, fontSize: 13, ), ), backgroundColor: AppColors.surfaceContainerHigh, behavior: SnackBarBehavior.floating, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), side: const BorderSide( color: AppColors.success, width: 1, ), ), duration: const Duration(seconds: 3), ), ); } }); return Scaffold( backgroundColor: AppColors.surface, body: Column( children: [ // ── Top bar ────────────────────────────────────────────────────── _TopBar(onBack: () => context.router.maybePop()), // ── Content ────────────────────────────────────────────────────── Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(UIConstants.pagePadding), child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 680), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Heading Text( 'Knowledge Base', style: GoogleFonts.inter( fontSize: 22, fontWeight: FontWeight.w700, color: AppColors.textPrimary, letterSpacing: -0.3, ), ), const SizedBox(height: UIConstants.spacing8), Text( 'Paste your fitness or university notes below. ' 'They will be split into chunks, embedded with the ' 'Nomic model, and used as context when you chat with ' 'the AI — no internet required.', style: GoogleFonts.inter( fontSize: 13, color: AppColors.textSecondary, height: 1.6, ), ), const SizedBox(height: UIConstants.spacing24), // ── Status card ────────────────────────────────────── _StatusCard(chunkCount: kbState.chunkCount), const SizedBox(height: UIConstants.spacing24), // ── Text input ─────────────────────────────────────── _SectionLabel('Paste Notes'), const SizedBox(height: UIConstants.spacing8), Container( decoration: BoxDecoration( color: AppColors.surfaceContainer, borderRadius: BorderRadius.circular( UIConstants.borderRadius, ), border: Border.all(color: AppColors.border), ), child: TextField( controller: _textController, style: GoogleFonts.inter( fontSize: 13, color: AppColors.textPrimary, height: 1.6, ), maxLines: 14, minLines: 8, decoration: InputDecoration( hintText: 'Paste lecture notes, programming guides, ' 'exercise descriptions, research summaries…', hintStyle: GoogleFonts.inter( fontSize: 13, color: AppColors.textMuted, height: 1.6, ), contentPadding: const EdgeInsets.all( UIConstants.cardPadding, ), border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, ), enabled: !kbState.isLoading, ), ), // ── Error message ──────────────────────────────────── if (kbState.errorMessage != null) ...[ const SizedBox(height: UIConstants.spacing12), _ErrorBanner(message: kbState.errorMessage!), ], const SizedBox(height: UIConstants.spacing16), // ── Action buttons ─────────────────────────────────── if (kbState.isLoading) _LoadingIndicator() else Row( children: [ Expanded( child: _SaveButton( onPressed: () => _save(controller), ), ), if (kbState.chunkCount > 0) ...[ const SizedBox(width: UIConstants.spacing12), _ClearButton( onPressed: () => _clear(controller), ), ], ], ), const SizedBox(height: UIConstants.spacing32), // ── How it works ───────────────────────────────────── _HowItWorksCard(), ], ), ), ), ), ), ], ), ); } } // ============================================================================= // Top bar // ============================================================================= class _TopBar extends StatelessWidget { const _TopBar({required this.onBack}); final VoidCallback onBack; @override Widget build(BuildContext context) { return Container( height: 52, padding: const EdgeInsets.symmetric(horizontal: UIConstants.spacing16), decoration: const BoxDecoration( color: AppColors.surfaceContainer, border: Border(bottom: BorderSide(color: AppColors.border)), ), child: Row( children: [ _IconBtn(icon: Icons.arrow_back_rounded, onTap: onBack), const SizedBox(width: UIConstants.spacing12), Text( 'Knowledge Base', style: GoogleFonts.inter( fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), ), ], ), ); } } // ============================================================================= // Status card // ============================================================================= class _StatusCard extends StatelessWidget { const _StatusCard({required this.chunkCount}); final int chunkCount; @override Widget build(BuildContext context) { final hasChunks = chunkCount > 0; return Container( padding: const EdgeInsets.symmetric( horizontal: UIConstants.spacing16, vertical: UIConstants.spacing12, ), decoration: BoxDecoration( color: hasChunks ? AppColors.successMuted : AppColors.surfaceContainer, borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), border: Border.all( color: hasChunks ? AppColors.success.withValues(alpha: 0.3) : AppColors.border, ), ), child: Row( children: [ Icon( hasChunks ? Icons.check_circle_outline_rounded : Icons.info_outline_rounded, size: 16, color: hasChunks ? AppColors.success : AppColors.textMuted, ), const SizedBox(width: UIConstants.spacing12), Expanded( child: Text( hasChunks ? '$chunkCount chunk${chunkCount == 1 ? '' : 's'} stored — ' 'AI chat will use these as context.' : 'No notes added yet. The AI chat will use only its base ' 'training knowledge.', style: GoogleFonts.inter( fontSize: 13, color: hasChunks ? AppColors.success : AppColors.textMuted, height: 1.4, ), ), ), ], ), ); } } // ============================================================================= // Loading indicator // ============================================================================= class _LoadingIndicator extends StatelessWidget { @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(vertical: UIConstants.spacing16), child: Row( children: [ const SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: AppColors.accent, ), ), const SizedBox(width: UIConstants.spacing12), Text( 'Generating embeddings… this may take a moment.', style: GoogleFonts.inter( fontSize: 13, color: AppColors.textSecondary, ), ), ], ), ); } } // ============================================================================= // Save button // ============================================================================= class _SaveButton extends StatefulWidget { const _SaveButton({required this.onPressed}); final VoidCallback onPressed; @override State<_SaveButton> createState() => _SaveButtonState(); } class _SaveButtonState extends State<_SaveButton> { bool _hovered = false; @override Widget build(BuildContext context) { return MouseRegion( onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: AnimatedContainer( duration: UIConstants.animationDuration, height: 42, decoration: BoxDecoration( color: _hovered ? AppColors.accent.withValues(alpha: 0.85) : AppColors.accent, borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), ), child: Material( color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), onTap: widget.onPressed, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon( Icons.save_outlined, color: AppColors.zinc950, size: 16, ), const SizedBox(width: UIConstants.spacing8), Text( 'Save to Knowledge Base', style: GoogleFonts.inter( fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.zinc950, ), ), ], ), ), ), ), ); } } // ============================================================================= // Clear button // ============================================================================= class _ClearButton extends StatefulWidget { const _ClearButton({required this.onPressed}); final VoidCallback onPressed; @override State<_ClearButton> createState() => _ClearButtonState(); } class _ClearButtonState extends State<_ClearButton> { bool _hovered = false; @override Widget build(BuildContext context) { return MouseRegion( onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: AnimatedContainer( duration: UIConstants.animationDuration, height: 42, decoration: BoxDecoration( color: _hovered ? AppColors.destructiveMuted : Colors.transparent, borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), border: Border.all( color: _hovered ? AppColors.destructive.withValues(alpha: 0.4) : AppColors.border, ), ), child: Material( color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), onTap: widget.onPressed, child: Padding( padding: const EdgeInsets.symmetric( horizontal: UIConstants.spacing16, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.delete_outline_rounded, size: 15, color: _hovered ? AppColors.destructive : AppColors.textMuted, ), const SizedBox(width: UIConstants.spacing8), Text( 'Clear All', style: GoogleFonts.inter( fontSize: 13, fontWeight: FontWeight.w500, color: _hovered ? AppColors.destructive : AppColors.textMuted, ), ), ], ), ), ), ), ), ); } } // ============================================================================= // Error banner // ============================================================================= class _ErrorBanner extends StatelessWidget { const _ErrorBanner({required this.message}); final String message; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(UIConstants.spacing12), decoration: BoxDecoration( color: AppColors.destructiveMuted, borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), border: Border.all( color: AppColors.destructive.withValues(alpha: 0.4), ), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Icon( Icons.error_outline_rounded, color: AppColors.destructive, size: 15, ), const SizedBox(width: UIConstants.spacing8), Expanded( child: Text( message, style: GoogleFonts.inter( fontSize: 12, color: AppColors.destructive, height: 1.5, ), ), ), ], ), ); } } // ============================================================================= // How it works info card // ============================================================================= class _HowItWorksCard extends StatelessWidget { @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(UIConstants.cardPadding), decoration: BoxDecoration( color: AppColors.surfaceContainer, borderRadius: BorderRadius.circular(UIConstants.borderRadius), border: Border.all(color: AppColors.border), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon( Icons.info_outline_rounded, size: 15, color: AppColors.textMuted, ), const SizedBox(width: UIConstants.spacing8), Text( 'How it works', style: GoogleFonts.inter( fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textSecondary, ), ), ], ), const SizedBox(height: UIConstants.spacing12), _Step( n: '1', text: 'Your text is split into ~500-character chunks at paragraph ' 'and sentence boundaries.', ), const SizedBox(height: UIConstants.spacing8), _Step( n: '2', text: 'Each chunk is embedded by the local Nomic model into a 768-dim ' 'vector and stored in the database.', ), const SizedBox(height: UIConstants.spacing8), _Step( n: '3', text: 'When you ask a question, the 3 most similar chunks are retrieved ' 'and injected into the AI system prompt as context.', ), const SizedBox(height: UIConstants.spacing8), _Step( n: '4', text: 'Everything stays on your device — no data leaves the machine.', ), ], ), ); } } class _Step extends StatelessWidget { const _Step({required this.n, required this.text}); final String n; final String text; @override Widget build(BuildContext context) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 20, height: 20, decoration: BoxDecoration( color: AppColors.surfaceContainerHigh, borderRadius: BorderRadius.circular(10), ), child: Center( child: Text( n, style: GoogleFonts.inter( fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.accent, ), ), ), ), const SizedBox(width: UIConstants.spacing8), Expanded( child: Text( text, style: GoogleFonts.inter( fontSize: 12, color: AppColors.textMuted, height: 1.5, ), ), ), ], ); } } // ============================================================================= // Small reusable widgets // ============================================================================= class _SectionLabel extends StatelessWidget { const _SectionLabel(this.label); final String label; @override Widget build(BuildContext context) { return Text( label, style: GoogleFonts.inter( fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textMuted, letterSpacing: 0.8, ), ); } } class _IconBtn extends StatefulWidget { const _IconBtn({required this.icon, required this.onTap}); final IconData icon; final VoidCallback onTap; @override State<_IconBtn> createState() => _IconBtnState(); } class _IconBtnState extends State<_IconBtn> { bool _hovered = false; @override Widget build(BuildContext context) { return MouseRegion( onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: GestureDetector( onTap: widget.onTap, child: AnimatedContainer( duration: UIConstants.animationDuration, width: 32, height: 32, decoration: BoxDecoration( color: _hovered ? AppColors.zinc800 : Colors.transparent, borderRadius: BorderRadius.circular(6), ), child: Icon( widget.icon, size: 18, color: _hovered ? AppColors.textPrimary : AppColors.textSecondary, ), ), ), ); } }