Refactor project structure and enhance stock watchlist functionality

This commit is contained in:
ImBenji
2026-04-21 12:09:16 +01:00
parent 491bf2bbea
commit ed617b7498
41 changed files with 6566 additions and 1289 deletions
+338 -115
View File
@@ -1,136 +1,359 @@
import 'dart:convert';
import 'dart:io';
import 'package:capstone_project/providers/settings.dart';
import 'package:capstone_project/utils/agrigator.dart';
import 'package:capstone_project/widgets/navbar.dart';
import 'package:capstone_project/data/stock_list.dart';
import 'package:capstone_project/models/watched_stock.dart';
import 'package:capstone_project/providers/watchlist.dart';
import 'package:capstone_project/services/stock_price_service.dart';
import 'package:capstone_project/widgets/app_shell.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 HomePage extends StatelessWidget {
const HomePage({super.key});
static GoRoute route = GoRoute(
path: "/",
builder: (context, state) => HomePage()
builder: (context, state) => const HomePage(),
);
@override
Widget build(BuildContext context) {
// TODO: implement build
return Scaffold(
headers: [
AppBar()
],
footers: [
ProjNavBar(
currentPage: "home",
)
],
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
return Consumer<WatchlistProvider>(
builder: (context, watchlist, _) {
final stocks = watchlist.stocks;
Text(
"Nothing here is final yet!",
).h1,
Gap(16),
Button.primary(
onPressed: () async {
print("Aggregating feeds...");
SettingsProvider settings = SettingsProvider.of(context);
// Fetch all feeds
List<Uri> feedUris = settings.feeds.map((feed) => Uri.parse(feed.url)).toList();
List<FeedItem> aggregatedItems = await fetchFeeds(feedUris);
// Save it to a file before generating embeddings
String agregatedJson = JsonEncoder.withIndent(' ').convert(aggregatedItems);
File agregatedJsonFile = File("${settings.applicationStorageLocation}/aggregated_feed.json");
await agregatedJsonFile.writeAsString(agregatedJson);
print("Aggregated feed saved to ${agregatedJsonFile.path}");
// Generate embeddings for all items
print("Generating embeddings for ${aggregatedItems.length} items...");
List<FeedItem> enrichedItems = [...aggregatedItems];
await generateEmbeddings(enrichedItems, settings.openAIApiKey);
// Save it to a file in the application storage location
String enrichedJson = JsonEncoder.withIndent(' ').convert(enrichedItems);
final file = File("${settings.applicationStorageLocation}/enriched_aggregated_feed.json");
await file.writeAsString(enrichedJson);
print("Enriched aggregated feed saved to ${file.path}");
// Filter out irrelevant items
print("Filtering relevant items...");
await generateKeywordEmbeddings(settings.openAIApiKey);
List<FeedItem> relevantItems = [...aggregatedItems]..removeWhere((item) => !isFeedItemRelevant(item));
String relevantJson = JsonEncoder.withIndent(' ').convert(relevantItems);
final fileRelevant = File("${settings.applicationStorageLocation}/relevant_aggregated_feed.json");
await fileRelevant.writeAsString(relevantJson);
print("Cut down from ${aggregatedItems.length} to ${relevantItems.length} relevant items.");
print("Relevant aggregated feed saved to ${fileRelevant.path}");
// For human readability, save a version without embeddings
List<FeedItem> readableItems = relevantItems.map((item) {
return FeedItem(
title: item.title,
link: item.link,
description: item.description,
);
}).toList();
String readableJson = JsonEncoder.withIndent(' ').convert(readableItems);
final fileReadable = File("${settings.applicationStorageLocation}/readable_relevant_aggregated_feed.json");
await fileReadable.writeAsString(readableJson);
print("Readable relevant aggregated feed saved to ${fileReadable.path}");
// Group by event
print("Grouping feed items by event...");
List<List<FeedItem>> groupedItems = groupFeedItemsByEvent(relevantItems);
List<List<Map<String, dynamic>>> groupedItemsJson = groupedItems.map((group) {
return group.map((item) => item.toJson()).toList();
}).toList();
String groupedJson = JsonEncoder.withIndent(' ').convert(groupedItemsJson);
final fileGrouped = File("${settings.applicationStorageLocation}/grouped_relevant_aggregated_feed.json");
await fileGrouped.writeAsString(groupedJson);
print("Grouped relevant aggregated feed saved to ${fileGrouped.path}");
// For human readability, save a version without embeddings
List<List<FeedItem>> readableGroupedItems = groupedItems.map((group) {
return group.map((item) {
return FeedItem(
title: item.title,
link: item.link,
description: item.description,
);
}).toList();
}).toList();
// Sort groups by size descending
readableGroupedItems.sort((a, b) => b.length.compareTo(a.length));
List<List<Map<String, dynamic>>> readableGroupedItemsJson = readableGroupedItems.map((group) {
return group.map((item) => item.toJson()).toList();
}).toList();
String readableGroupedJson = JsonEncoder.withIndent(' ').convert(readableGroupedItemsJson);
final fileReadableGrouped = File("${settings.applicationStorageLocation}/readable_grouped_relevant_aggregated_feed.json");
await fileReadableGrouped.writeAsString(readableGroupedJson);
print("Readable grouped relevant aggregated feed saved to ${fileReadableGrouped.path}");
},
child: Text(
"Aggregate your feeds"
return AugorShell(
titleTag: 'Watchlist',
headerLeading: [
_AppName(),
],
headerTrailing: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Button.primary(
onPressed: () => showDialog(
context: context,
builder: (context) => const AddStockDialog(),
),
child: const Text('Add stock'),
),
)
),
],
statusLeft: StatusText('${stocks.length} stock${stocks.length == 1 ? '' : 's'} tracked'),
child: stocks.isEmpty
? Center(
child: SizedBox(
width: 520,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Your watchlist is empty').h2,
const Gap(8),
Text('Add a stock to start tracking signals and news.').muted,
const Gap(16),
Button.primary(
onPressed: () => showDialog(
context: context,
builder: (context) => const AddStockDialog(),
),
child: const Text('Add your first stock'),
),
],
).withPadding(all: 16),
),
)
: SingleChildScrollView(
child: Center(
child: SizedBox(
width: 1100,
child: Wrap(
spacing: 16,
runSpacing: 16,
children: [
for (final stock in stocks)
SizedBox(
width: 340,
child: StockCard(stock: stock),
),
],
).withPadding(all: 20),
),
),
),
);
},
);
}
}
class _AppName extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return RichText(
text: TextSpan(
text: 'Augor',
style: GoogleFonts.ibmPlexMono(
fontSize: 13,
height: 1,
fontWeight: FontWeight.w700,
color: theme.colorScheme.foreground,
),
),
);
}
}
class StockCard extends StatefulWidget {
final WatchedStock stock;
const StockCard({super.key, required this.stock});
@override
State<StockCard> createState() => _StockCardState();
}
class _StockCardState extends State<StockCard> {
static final StockPriceService _priceService = StockPriceService();
List<StockPricePoint> _prices = [];
bool _hovered = false;
@override
void initState() {
super.initState();
_loadPrices();
}
@override
void didUpdateWidget(covariant StockCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.stock.ticker != widget.stock.ticker) {
_loadPrices();
}
}
Future<void> _loadPrices() async {
try {
final prices = await _priceService.fetchPriceHistory(widget.stock.ticker);
if (!mounted) return;
setState(() => _prices = prices);
} catch (e) {
if (!mounted) return;
setState(() => _prices = []);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final stock = widget.stock;
final signal = stock.latestSignal;
final probability = signal == null
? 'No analysis yet'
: '${(signal.impact * 100).toStringAsFixed(1)}% impact';
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => GoRouter.of(context).go('/stock/${stock.ticker}'),
child: AnimatedContainer(
duration: const Duration(milliseconds: 80),
decoration: BoxDecoration(
color: _hovered
? theme.colorScheme.background
: theme.colorScheme.background.withValues(alpha: 0.85),
border: Border.all(color: theme.colorScheme.border, width: 1),
borderRadius: BorderRadius.circular(6),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: _hovered ? 0.2 : 0.12),
blurRadius: 4,
spreadRadius: _hovered ? 3 : 2,
),
],
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(stock.ticker).h2,
const Gap(4),
Text(stock.companyName).muted,
],
),
),
_SignalBadge(direction: signal?.direction ?? 'neutral'),
],
),
const Gap(16),
Text(probability).semiBold,
const Gap(12),
StockPriceChart(
prices: _prices,
signals: stock.signalHistory,
height: 80,
compact: true,
),
],
),
),
),
);
}
}
class _SignalBadge extends StatelessWidget {
final String direction;
const _SignalBadge({required this.direction});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = switch (direction) {
'positive' => const Color(0xFF16a34a),
'negative' => const Color(0xFFdc2626),
_ => theme.colorScheme.mutedForeground,
};
final bg = switch (direction) {
'positive' => const Color(0xFF16a34a).withValues(alpha: 0.15),
'negative' => const Color(0xFFdc2626).withValues(alpha: 0.15),
_ => theme.colorScheme.border.withValues(alpha: 0.5),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: color.withValues(alpha: 0.3), width: 1),
),
child: RichText(
text: TextSpan(
text: direction,
style: GoogleFonts.ibmPlexMono(
fontSize: 10,
height: 1,
fontWeight: FontWeight.w600,
color: color,
),
),
),
);
}
}
class AddStockDialog extends StatefulWidget {
const AddStockDialog({super.key});
@override
State<AddStockDialog> createState() => _AddStockDialogState();
}
class _AddStockDialogState extends State<AddStockDialog> {
String _query = '';
@override
Widget build(BuildContext context) {
final normalizedQuery = _query.trim().toLowerCase();
final results = stockList.where((stock) {
if (normalizedQuery.isEmpty) return true;
final ticker = (stock['ticker'] ?? '').toLowerCase();
final name = (stock['name'] ?? '').toLowerCase();
return ticker.contains(normalizedQuery) || name.contains(normalizedQuery);
}).toList();
return AlertDialog(
title: const Text('Add stock'),
content: SizedBox(
width: 420,
height: 420,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
placeholder: const Text('Search ticker or company'),
onChanged: (value) => setState(() => _query = value),
),
const Gap(16),
Expanded(
child: results.isEmpty
? Center(child: Text('No matching stocks found.').muted)
: ListView.separated(
itemCount: results.length,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) {
final stock = results[index];
final ticker = stock['ticker'] ?? '';
final name = stock['name'] ?? '';
final alreadyAdded = context
.watch<WatchlistProvider>()
.stocks
.any((w) => w.ticker == ticker);
return GestureDetector(
onTap: alreadyAdded
? null
: () async {
final watchlist = WatchlistProvider.of(context);
await watchlist.addStock(ticker, name);
if (context.mounted) Navigator.of(context).pop();
},
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: alreadyAdded
? Theme.of(context).colorScheme.secondary.withValues(alpha: 0.5)
: null,
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(ticker).semiBold,
const Gap(2),
Text(name).muted.small,
],
),
),
if (alreadyAdded) const Text('Added').muted.small,
],
),
),
);
},
),
),
],
),
),
);
}
}
}
+70 -421
View File
@@ -1,16 +1,16 @@
import 'package:capstone_project/providers/settings.dart';
import 'package:capstone_project/widgets/navbar.dart';
import 'package:capstone_project/widgets/app_shell.dart';
import 'package:capstone_project/widgets/panel_layout.dart';
import 'package:file_picker/file_picker.dart';
import 'package:go_router/go_router.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
class SettingsPage extends StatefulWidget {
class SettingsPage extends StatefulWidget {
static GoRoute route = GoRoute(
path: "/settings",
builder: (context, state) => SettingsPage()
builder: (context, state) => SettingsPage(),
);
@override
@@ -19,431 +19,80 @@ class SettingsPage extends StatefulWidget {
class _SettingsPageState extends State<SettingsPage> {
bool _isLoading = true;
String apiKey = "";
List<Feed> feeds = [];
String appStorageLocation = "";
@override
void initState() {
// TODO: implement initState
super.initState();
loadPage();
}
void loadPage() async {
SettingsProvider settings = SettingsProvider.of(context);
setState(() {
_isLoading = true;
});
apiKey = settings.openAIApiKey;
feeds = settings.feeds;
appStorageLocation = settings.applicationStorageLocation;
setState(() {
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
final settings = SettingsProvider.of(context);
final apiKey = settings.openRouterApiKey;
final storagePath = settings.applicationStorageLocation;
if (_isLoading) {
return Scaffold(
child: Center(
child: CircularProgressIndicator(),
final fields = <PanelField>[
PanelField(
section: 'API',
label: const Text('OpenRouter key'),
child: TextField(
placeholder: const Text('sk-or-...'),
initialValue: apiKey.isEmpty
? ''
: '${apiKey.substring(0, apiKey.length >= 8 ? 8 : apiKey.length)}xxxxxx',
onChanged: (value) => settings.setOpenRouterApiKey(value),
),
);
}
),
return Scaffold(
headers: [
AppBar(
title: Text("Settings"),
)
],
footers: [
ProjNavBar(
currentPage: "settings",
)
],
child: Center(
child: SizedBox(
width: 600,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Open AI"
).h4,
Gap(12),
Text(
"API Key"
).small.normal,
Gap(8),
TextField(
placeholder: Text(
"Enter your OpenAI API key"
),
initialValue: apiKey.substring(0, 8) + "xxxxxx (Redacted for security)" ,
onChanged: (value) {
SettingsProvider settings = SettingsProvider.of(context);
settings.setOpenAIApiKey(value);
},
PanelField(
section: 'Application Data',
label: const Text('Storage path'),
child: Row(
children: [
Expanded(
child: Text(
storagePath.isEmpty ? 'Default' : storagePath,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.mutedForeground,
),
Gap(8),
if (apiKey.isEmpty)
DestructiveBadge(
child: Text(
"API key is required to use AI features."
),
),
Gap(16),
Divider(),
Gap(16),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"News Feeds"
).h4,
Gap(4),
Text(
"Manage your RSS news feeds."
).muted.small,
],
),
),
Button.outline(
child: Text(
"Add Feed"
),
onPressed: () {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AddFeedDialog()
).then((value) {
loadPage();
});
},
)
],
),
Gap(24),
for (Feed feed in feeds) ...[
Row(
children: [
Expanded(
child: Text(
feed.title.isNotEmpty ? feed.title : feed.url,
style: TextStyle(
decoration: feed.enabled ? TextDecoration.none : TextDecoration.lineThrough
),
),
),
Builder(
builder: (context2) {
return GhostButton(
density: ButtonDensity.dense,
child: Icon(
LucideIcons.ellipsis,
),
onPressed: () {
showDropdown(
context: context2,
builder: (_) {
return DropdownMenu(
children: [
MenuButton(
child: Text(
"Edit"
),
trailing: Icon(
LucideIcons.filePen
),
onPressed: (_) {
// Navigator.of(context).pop();
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AddFeedDialog(
existingFeed: feed,
)
).then((value) {
loadPage();
});
},
)
],
);
}
);
},
);
}
),
Gap(4),
Switch(
value: feed.enabled,
onChanged: (value) {
SettingsProvider settings = SettingsProvider.of(context);
settings.updateFeed(
feed.id,
enabled: value
);
loadPage();
},
),
],
),
SelectableText(
feed.url,
style: TextStyle(
decoration: feed.enabled ? TextDecoration.none : TextDecoration.lineThrough
),
).muted.small,
Gap(12),
],
Gap(12),
Divider(),
Gap(16),
Text(
"Application Data"
).h4,
Gap(12),
Text(
"Application Storage Location"
).small.normal,
Gap(8),
Row(
children: [
Expanded(
child: TextField(
placeholder: Text(
"Select a diretory using Browse"
),
initialValue: appStorageLocation,
enabled: false,
),
),
Gap(8),
Button.outline(
child: Text(
"Browse"
),
onPressed: () {
FilePicker.platform.getDirectoryPath().then((selectedPath) {
if (selectedPath != null) {
SettingsProvider settings = SettingsProvider.of(context);
settings.setApplicationStorageLocation(selectedPath);
loadPage();
}
});
},
),
Gap(8),
IconButton.destructive(
icon: Icon(
LucideIcons.refreshCcw
),
onPressed: () async {
SettingsProvider settings = SettingsProvider.of(context);
settings.setApplicationStorageLocation((await getApplicationDocumentsDirectory()).path);
loadPage();
},
)
],
),
],
).withMargin(
all: 10
overflow: TextOverflow.ellipsis,
),
),
),
const Gap(8),
Button.outline(
onPressed: () {
FilePicker.platform.getDirectoryPath().then((path) {
if (path != null) {
settings.setApplicationStorageLocation(path);
setState(() {});
}
});
},
child: const Text('Browse'),
),
const Gap(4),
IconButton.destructive(
icon: const Icon(LucideIcons.refreshCcw),
onPressed: () async {
final dir = await getApplicationDocumentsDirectory();
settings.setApplicationStorageLocation(dir.path);
setState(() {});
},
),
],
),
).withPadding(all: 24),
),
];
return AugorShell(
titleTag: 'Settings',
statusLeft: const StatusText('Augor · Settings'),
child: SingleChildScrollView(
child: PanelList(fields: fields),
),
);
}
}
class AddFeedDialog extends StatelessWidget {
Feed? existingFeed;
AddFeedDialog({super.key, this.existingFeed});
InputKey feedTitleKey = InputKey("feed_title");
InputKey feedUrlKey = InputKey("feed_url");
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(
existingFeed == null ? "Add Feed" : "Edit Feed"
),
content: SizedBox(
width: 400,
child: Form(
onSubmit: (context, values) {
String title = values[feedTitleKey] ?? "";
String url = values[feedUrlKey] ?? "";
SettingsProvider settings = SettingsProvider.of(context);
if (existingFeed != null) {
settings.updateFeed(
existingFeed!.id,
title: title,
url: url
);
} else {
settings.addFeed(Feed(
title: title,
url: url,
enabled: true
));
}
Navigator.of(context).pop();
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Add a custom RSS feed to your news sources."
).muted,
Gap(16),
FormField(
key: feedTitleKey,
label: Text(
"Title"
),
validator: ConditionalValidator((value) {
if (value is String) {
return value.trim().isNotEmpty;
}
return false;
}, message: "Title cannot be empty"),
child: TextField(
initialValue: existingFeed?.title,
),
),
Gap(12),
FormField(
key: feedUrlKey,
label: Text(
"Resource URL"
),
validator: ConditionalValidator((value) {
if (value is String) {
return value.trim().isNotEmpty;
}
return false;
}, message: "URL cannot be empty"),
child: TextField(
initialValue: existingFeed?.url,
)
),
Gap(24),
Text(
"Only use valid RSS feed URLs. Preferably only use sites you trust, and specifically business and financial news sources."
),
Gap(24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Button.outline(
child: Text(
"Cancel"
),
onPressed: () {
Navigator.of(context).pop();
},
),
Gap(8),
SubmitButton(
child: Text(
existingFeed != null ? "Update Feed" : "Add Feed"
),
),
],
)
],
),
),
),
);
}
}
+788
View File
@@ -0,0 +1,788 @@
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!,
],
],
);
}
}