Auger/lib/pages/stock_dashboard.dart

788 lines
27 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<StockDashboardPage> createState() => _StockDashboardPageState();
}
class _StockDashboardPageState extends State<StockDashboardPage> {
static final StockPriceService _priceService = StockPriceService();
bool _isProcessing = false;
bool _isLoadingPrices = false;
String _status = 'Ready';
String? _error;
List<StockPricePoint> _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<void> _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<WatchlistProvider>(
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<void> _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<int> 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!,
],
],
);
}
}