import 'package:capstone_project/models/watched_stock.dart'; import 'package:capstone_project/providers/settings.dart'; import 'package:capstone_project/providers/watchlist.dart'; import 'package:capstone_project/services/stock_price_service.dart'; import 'package:capstone_project/utils/event_clusterer.dart'; import 'package:capstone_project/utils/signal_generator.dart'; import 'package:capstone_project/services/news_search_service.dart'; import 'package:capstone_project/widgets/app_shell.dart'; import 'package:capstone_project/widgets/event_signal_card.dart'; import 'package:capstone_project/widgets/signal_probability_chart.dart'; import 'package:capstone_project/widgets/stock_price_chart.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; class StockDashboardPage extends StatefulWidget { final String ticker; const StockDashboardPage({super.key, required this.ticker}); static GoRoute route = GoRoute( path: '/stock/:ticker', builder: (context, state) => StockDashboardPage( ticker: state.pathParameters['ticker']!, ), ); @override State createState() => _StockDashboardPageState(); } class _StockDashboardPageState extends State { static final StockPriceService _priceService = StockPriceService(); bool _isProcessing = false; bool _isLoadingPrices = false; String _status = 'Ready'; String? _error; List _prices = []; _TimeRange _selectedRange = _TimeRange.oneMonth; int _periodOffset = 0; // 0 = current period, negative = further back DateTime get _fromDate { final now = DateTime.now(); switch (_selectedRange) { case _TimeRange.oneDay: final d = DateTime(now.year, now.month, now.day).add(Duration(days: _periodOffset)); return d; case _TimeRange.oneWeek: final monday = now.subtract(Duration(days: now.weekday - 1)); return DateTime(monday.year, monday.month, monday.day).add(Duration(days: _periodOffset * 7)); case _TimeRange.oneMonth: return DateTime(now.year, now.month + _periodOffset, 1); case _TimeRange.oneYear: return DateTime(now.year + _periodOffset, 1, 1); case _TimeRange.max: return DateTime(2000, 1, 1); } } DateTime get _toDate { final now = DateTime.now(); switch (_selectedRange) { case _TimeRange.oneDay: return _fromDate.add(const Duration(hours: 23, minutes: 59, seconds: 59)); case _TimeRange.oneWeek: return _fromDate.add(const Duration(days: 6, hours: 23, minutes: 59, seconds: 59)); case _TimeRange.oneMonth: final f = _fromDate; return DateTime(f.year, f.month + 1, 0, 23, 59, 59); case _TimeRange.oneYear: return DateTime(now.year + _periodOffset, 12, 31, 23, 59, 59); case _TimeRange.max: return now; } } // null = all, 'forecasting', 'reactive' String? _natureFilter; @override void initState() { super.initState(); _loadPrices(); } @override void didUpdateWidget(covariant StockDashboardPage oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.ticker != widget.ticker) { _loadPrices(); } } Future _loadPrices() async { setState(() => _isLoadingPrices = true); try { final (range, interval) = switch (_selectedRange) { _TimeRange.oneDay => ('5d', '30m'), _TimeRange.oneWeek => ('3mo', '1d'), _TimeRange.oneMonth => ('1y', '1d'), _TimeRange.oneYear => ('5y', '1wk'), _TimeRange.max => ('max', '1mo'), }; final prices = await _priceService.fetchPriceHistory(widget.ticker, range: range, interval: interval); if (!mounted) return; setState(() => _prices = prices); } catch (e) { if (!mounted) return; setState(() => _prices = []); } finally { if (mounted) setState(() => _isLoadingPrices = false); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); return Consumer( builder: (context, watchlist, _) { final stock = watchlist.stocks .where((item) => item.ticker == widget.ticker) .firstOrNull; if (stock == null) { return AugorShell( titleTag: widget.ticker, headerLeading: [_BackButton()], child: Center( child: SizedBox( width: 520, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text('Stock not found').h2, const Gap(8), Text('This watchlist item no longer exists.').muted, const Gap(16), Button.outline( onPressed: () => GoRouter.of(context).go('/'), child: const Text('Back to watchlist'), ), ], ).withPadding(all: 16), ), ), ); } final signalCount = stock.signalHistory.length; final latestSignal = stock.latestSignal; return AugorShell( titleTag: stock.ticker, headerLeading: [ _BackButton(), const SizedBox(width: 12), _TickerLabel(stock: stock), ], headerTrailing: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Row( mainAxisSize: MainAxisSize.min, children: [ if (stock.signalHistory.isNotEmpty) Button.outline( onPressed: _isProcessing ? null : () async { await WatchlistProvider.of(context).updateSignals(stock.ticker, []); }, child: const Text('Clear'), ), const SizedBox(width: 8), Button.primary( onPressed: _isProcessing ? null : () => _runAnalysis(context, stock), child: Text(_isProcessing ? 'Analyzing...' : 'Run Analysis'), ), ], ), ), ], statusLeft: StatusText(_status), statusRight: latestSignal != null ? StatusText('impact ${(latestSignal.impact * 100).toStringAsFixed(0)}% · $signalCount signal${signalCount == 1 ? '' : 's'}') : null, child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // ── left: charts (fixed, non-scrolling) ────────────────────── SizedBox( width: 520, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_error != null) Container( margin: const EdgeInsets.fromLTRB(20, 20, 0, 0), width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: const Color(0xFFdc2626).withValues(alpha: 0.1), border: Border.all(color: const Color(0xFFdc2626).withValues(alpha: 0.3), width: 1), borderRadius: BorderRadius.circular(4), ), child: Text(_error!, style: const TextStyle(color: Color(0xFFdc2626), fontSize: 12)), ), if (_isProcessing) Padding( padding: const EdgeInsets.fromLTRB(20, 16, 0, 0), child: const SizedBox( width: double.infinity, child: LinearProgressIndicator(), ), ), Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), child: _TimeRangeBar( selected: _selectedRange, offset: _periodOffset, onRangeChanged: (r) { setState(() { _selectedRange = r; _periodOffset = 0; }); _loadPrices(); }, onOffsetChanged: (o) => setState(() => _periodOffset = o), ), ), Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SectionHeader( title: 'Price', trailing: _isLoadingPrices ? Text('loading...').muted.small : null, ), const Gap(12), StockPriceChart( prices: _prices.where((p) => !p.date.isBefore(_fromDate) && !p.date.isAfter(_toDate) ).toList(), signals: stock.signalHistory.where((s) => !s.createdAt.isBefore(_fromDate) && !s.createdAt.isAfter(_toDate) && (_natureFilter == null || s.nature == _natureFilter) ).toList(), height: 220, ), const Gap(28), _SectionHeader(title: 'Signal history'), const Gap(12), SignalProbabilityChart( signals: stock.signalHistory.where((s) => !s.createdAt.isBefore(_fromDate) && !s.createdAt.isAfter(_toDate) && (_natureFilter == null || s.nature == _natureFilter) ).toList(), height: 200, ), ], ), ), ], ), ), // vertical divider between columns VerticalDivider(color: theme.colorScheme.border, width: 1), // ── right: scrollable signals list ─────────────────────────── Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // filter bar Container( padding: const EdgeInsets.fromLTRB(20, 16, 20, 12), decoration: BoxDecoration( border: Border(bottom: BorderSide(color: theme.colorScheme.border, width: 1)), ), child: Row( children: [ _SectionHeader( title: 'Signals', trailing: Text('${stock.signalHistory.length}').muted, ), const Spacer(), _NatureFilterChip( label: 'All', active: _natureFilter == null, onTap: () => setState(() => _natureFilter = null), ), const SizedBox(width: 4), _NatureFilterChip( label: 'Forecasting', active: _natureFilter == 'forecasting', onTap: () => setState(() => _natureFilter = _natureFilter == 'forecasting' ? null : 'forecasting'), ), const SizedBox(width: 4), _NatureFilterChip( label: 'Reactive', active: _natureFilter == 'reactive', onTap: () => setState(() => _natureFilter = _natureFilter == 'reactive' ? null : 'reactive'), ), ], ), ), // list Expanded( child: Builder(builder: (context) { final filtered = stock.signalHistory .where((s) => _natureFilter == null || s.nature == _natureFilter) .toList() ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); if (stock.signalHistory.isEmpty) { return Center( child: Padding( padding: const EdgeInsets.all(32), child: Text('No signals yet. Run analysis to populate this dashboard.').muted, ), ); } if (filtered.isEmpty) { return Center( child: Padding( padding: const EdgeInsets.all(32), child: Text('No $_natureFilter signals in this window.').muted, ), ); } return ListView.separated( padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), itemCount: filtered.length, separatorBuilder: (_, __) => const SizedBox(height: 10), itemBuilder: (_, i) => EventSignalCard(signal: filtered[i]), ); }), ), ], ), ), ], ), ); }, ); } Future _runAnalysis(BuildContext context, WatchedStock stock) async { final settings = SettingsProvider.of(context); final watchlist = WatchlistProvider.of(context); if (settings.openRouterApiKey.trim().isEmpty) { setState(() { _error = 'Add your OpenRouter API key in Settings first.'; _status = 'Missing API key'; }); return; } setState(() { _isProcessing = true; _error = null; _status = 'Fetching articles...'; }); try { setState(() => _status = 'Fetching news...'); final newsService = NewsSearchService(); final scopedItems = await newsService.search( ticker: stock.ticker, companyName: stock.companyName, from: _fromDate, to: _toDate, ); if (scopedItems.isEmpty) { await watchlist.updateSignals(stock.ticker, []); setState(() => _status = 'Done — 0 matching articles'); return; } setState(() => _status = 'Clustering ${scopedItems.length} articles...'); final clusterer = EventClusterer(); final clusters = await clusterer.cluster(scopedItems); if (clusters.isEmpty) { await watchlist.updateSignals(stock.ticker, []); setState(() => _status = 'Done — 0 event clusters'); return; } setState(() => _status = 'Generating signals (${clusters.length} clusters)...'); final signalGenerator = SignalGenerator(apiKey: settings.openRouterApiKey); final signals = await signalGenerator.generateSignals( clusters, ticker: stock.ticker, companyName: stock.companyName, ); await watchlist.updateSignals(stock.ticker, signals); await _loadPrices(); if (!mounted) return; setState(() => _status = 'Done — ${signals.length} signal${signals.length == 1 ? '' : 's'}'); } catch (e) { print('Stock analysis failed for ${stock.ticker}: $e'); if (!mounted) return; setState(() { _error = e.toString(); _status = 'Failed'; }); } finally { if (mounted) setState(() => _isProcessing = false); } } } class _BackButton extends StatelessWidget { @override Widget build(BuildContext context) { return GhostButton( density: ButtonDensity.dense, onPressed: () => GoRouter.of(context).go('/'), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(LucideIcons.arrowLeft, size: 14), const SizedBox(width: 4), const Text('Back'), ], ), ); } } class _TickerLabel extends StatelessWidget { final WatchedStock stock; const _TickerLabel({required this.stock}); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ RichText( text: TextSpan( text: stock.ticker, style: GoogleFonts.ibmPlexMono( fontSize: 13, height: 1, fontWeight: FontWeight.w700, color: theme.colorScheme.foreground, ), ), ), const SizedBox(height: 2), RichText( text: TextSpan( text: stock.companyName, style: GoogleFonts.ibmPlexMono( fontSize: 10, height: 1, fontWeight: FontWeight.w500, color: theme.colorScheme.mutedForeground, ), ), ), ], ); } } enum _TimeRange { oneDay, oneWeek, oneMonth, oneYear, max } class _TimeRangeBar extends StatelessWidget { final _TimeRange selected; final int offset; final ValueChanged<_TimeRange> onRangeChanged; final ValueChanged onOffsetChanged; const _TimeRangeBar({ required this.selected, required this.offset, required this.onRangeChanged, required this.onOffsetChanged, }); static const _labels = { _TimeRange.oneDay: '1D', _TimeRange.oneWeek: '1W', _TimeRange.oneMonth: '1M', _TimeRange.oneYear: '1Y', _TimeRange.max: 'MAX', }; String _periodLabel(DateTime from, DateTime to) { const months = ['Jan','Feb','Mar','Apr','May','Jun', 'Jul','Aug','Sep','Oct','Nov','Dec']; switch (selected) { case _TimeRange.oneDay: return '${months[from.month - 1]} ${from.day}, ${from.year}'; case _TimeRange.oneWeek: if (from.month == to.month) { return '${months[from.month - 1]} ${from.day}–${to.day}'; } return '${months[from.month - 1]} ${from.day} – ${months[to.month - 1]} ${to.day}'; case _TimeRange.oneMonth: return '${months[from.month - 1]} ${from.year}'; case _TimeRange.oneYear: return '${from.year}'; case _TimeRange.max: return 'All Time'; } } DateTime _computeFrom() { final now = DateTime.now(); switch (selected) { case _TimeRange.oneDay: return DateTime(now.year, now.month, now.day).add(Duration(days: offset)); case _TimeRange.oneWeek: final monday = now.subtract(Duration(days: now.weekday - 1)); return DateTime(monday.year, monday.month, monday.day).add(Duration(days: offset * 7)); case _TimeRange.oneMonth: return DateTime(now.year, now.month + offset, 1); case _TimeRange.oneYear: return DateTime(now.year + offset, 1, 1); case _TimeRange.max: return DateTime(2000, 1, 1); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); final monoStyle = GoogleFonts.ibmPlexMono( fontSize: 10, height: 1, fontWeight: FontWeight.w600, color: theme.colorScheme.mutedForeground, ); final from = _computeFrom(); // reuse parent's _toDate logic via same calculation final now = DateTime.now(); final DateTime to; switch (selected) { case _TimeRange.oneDay: to = from.add(const Duration(hours: 23, minutes: 59, seconds: 59)); case _TimeRange.oneWeek: to = from.add(const Duration(days: 6, hours: 23, minutes: 59, seconds: 59)); case _TimeRange.oneMonth: to = DateTime(from.year, from.month + 1, 0, 23, 59, 59); case _TimeRange.oneYear: to = DateTime(now.year + offset, 12, 31, 23, 59, 59); case _TimeRange.max: to = now; } final canGoForward = selected != _TimeRange.max && offset < 0; return Row( children: [ // preset chips Container( decoration: BoxDecoration( border: Border.all(color: theme.colorScheme.border, width: 1), borderRadius: BorderRadius.circular(4), ), child: Row( mainAxisSize: MainAxisSize.min, children: _TimeRange.values.map((range) { final isActive = selected == range; final isLast = range == _TimeRange.max; return GestureDetector( onTap: () => onRangeChanged(range), child: AnimatedContainer( duration: const Duration(milliseconds: 80), padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5), decoration: BoxDecoration( color: isActive ? theme.colorScheme.border : Colors.transparent, border: isLast ? null : Border( right: BorderSide(color: theme.colorScheme.border, width: 1), ), ), child: RichText( text: TextSpan( text: _labels[range], style: monoStyle.copyWith( color: isActive ? theme.colorScheme.foreground : theme.colorScheme.mutedForeground, ), ), ), ), ); }).toList(), ), ), if (selected != _TimeRange.max) ...[ const SizedBox(width: 8), // prev GestureDetector( onTap: () => onOffsetChanged(offset - 1), child: Container( padding: const EdgeInsets.all(5), decoration: BoxDecoration( border: Border.all(color: theme.colorScheme.border, width: 1), borderRadius: BorderRadius.circular(4), ), child: Icon(LucideIcons.chevronLeft, size: 10, color: theme.colorScheme.mutedForeground), ), ), const SizedBox(width: 4), // period label Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( border: Border.all(color: theme.colorScheme.border, width: 1), borderRadius: BorderRadius.circular(4), ), child: RichText( text: TextSpan( text: _periodLabel(from, to), style: monoStyle.copyWith(color: theme.colorScheme.foreground), ), ), ), const SizedBox(width: 4), // next GestureDetector( onTap: canGoForward ? () => onOffsetChanged(offset + 1) : null, child: AnimatedContainer( duration: const Duration(milliseconds: 80), padding: const EdgeInsets.all(5), decoration: BoxDecoration( border: Border.all( color: canGoForward ? theme.colorScheme.border : theme.colorScheme.border.withValues(alpha: 0.3), width: 1, ), borderRadius: BorderRadius.circular(4), ), child: Icon( LucideIcons.chevronRight, size: 10, color: canGoForward ? theme.colorScheme.mutedForeground : theme.colorScheme.mutedForeground.withValues(alpha: 0.3), ), ), ), ], ], ); } } class _NatureFilterChip extends StatefulWidget { final String label; final bool active; final VoidCallback onTap; const _NatureFilterChip({required this.label, required this.active, required this.onTap}); @override State<_NatureFilterChip> createState() => _NatureFilterChipState(); } class _NatureFilterChipState extends State<_NatureFilterChip> { bool _hovered = false; @override Widget build(BuildContext context) { final theme = Theme.of(context); return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: GestureDetector( onTap: widget.onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 80), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: widget.active ? theme.colorScheme.border : _hovered ? theme.colorScheme.border.withValues(alpha: 0.4) : Colors.transparent, borderRadius: BorderRadius.circular(4), border: Border.all( color: widget.active ? theme.colorScheme.border : theme.colorScheme.border.withValues(alpha: 0.5), width: 1, ), ), child: RichText( text: TextSpan( text: widget.label, style: GoogleFonts.ibmPlexMono( fontSize: 10, height: 1, fontWeight: FontWeight.w600, color: widget.active ? theme.colorScheme.foreground : theme.colorScheme.mutedForeground, ), ), ), ), ), ); } } class _SectionHeader extends StatelessWidget { final String title; final Widget? trailing; const _SectionHeader({required this.title, this.trailing}); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Row( children: [ Text( title, style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 0.4, color: theme.colorScheme.foreground, ), ), if (trailing != null) ...[ const Gap(8), trailing!, ], ], ); } }