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
+123
View File
@@ -0,0 +1,123 @@
const List<Map<String, String>> stockList = [
{'ticker': 'AAPL', 'name': 'Apple Inc.'},
{'ticker': 'MSFT', 'name': 'Microsoft Corporation'},
{'ticker': 'NVDA', 'name': 'NVIDIA Corporation'},
{'ticker': 'AMZN', 'name': 'Amazon.com, Inc.'},
{'ticker': 'GOOGL', 'name': 'Alphabet Inc. Class A'},
{'ticker': 'GOOG', 'name': 'Alphabet Inc. Class C'},
{'ticker': 'META', 'name': 'Meta Platforms, Inc.'},
{'ticker': 'TSLA', 'name': 'Tesla, Inc.'},
{'ticker': 'BRK.B', 'name': 'Berkshire Hathaway Inc. Class B'},
{'ticker': 'JPM', 'name': 'JPMorgan Chase & Co.'},
{'ticker': 'AVGO', 'name': 'Broadcom Inc.'},
{'ticker': 'LLY', 'name': 'Eli Lilly and Company'},
{'ticker': 'V', 'name': 'Visa Inc.'},
{'ticker': 'MA', 'name': 'Mastercard Incorporated'},
{'ticker': 'XOM', 'name': 'Exxon Mobil Corporation'},
{'ticker': 'UNH', 'name': 'UnitedHealth Group Incorporated'},
{'ticker': 'COST', 'name': 'Costco Wholesale Corporation'},
{'ticker': 'WMT', 'name': 'Walmart Inc.'},
{'ticker': 'JNJ', 'name': 'Johnson & Johnson'},
{'ticker': 'PG', 'name': 'The Procter & Gamble Company'},
{'ticker': 'HD', 'name': 'The Home Depot, Inc.'},
{'ticker': 'ABBV', 'name': 'AbbVie Inc.'},
{'ticker': 'BAC', 'name': 'Bank of America Corporation'},
{'ticker': 'KO', 'name': 'The Coca-Cola Company'},
{'ticker': 'PEP', 'name': 'PepsiCo, Inc.'},
{'ticker': 'MRK', 'name': 'Merck & Co., Inc.'},
{'ticker': 'CVX', 'name': 'Chevron Corporation'},
{'ticker': 'ORCL', 'name': 'Oracle Corporation'},
{'ticker': 'ADBE', 'name': 'Adobe Inc.'},
{'ticker': 'CRM', 'name': 'Salesforce, Inc.'},
{'ticker': 'AMD', 'name': 'Advanced Micro Devices, Inc.'},
{'ticker': 'NFLX', 'name': 'Netflix, Inc.'},
{'ticker': 'CSCO', 'name': 'Cisco Systems, Inc.'},
{'ticker': 'TMO', 'name': 'Thermo Fisher Scientific Inc.'},
{'ticker': 'ACN', 'name': 'Accenture plc'},
{'ticker': 'MCD', 'name': 'McDonald\'s Corporation'},
{'ticker': 'ABT', 'name': 'Abbott Laboratories'},
{'ticker': 'LIN', 'name': 'Linde plc'},
{'ticker': 'DHR', 'name': 'Danaher Corporation'},
{'ticker': 'QCOM', 'name': 'QUALCOMM Incorporated'},
{'ticker': 'INTU', 'name': 'Intuit Inc.'},
{'ticker': 'TXN', 'name': 'Texas Instruments Incorporated'},
{'ticker': 'IBM', 'name': 'International Business Machines Corporation'},
{'ticker': 'AMGN', 'name': 'Amgen Inc.'},
{'ticker': 'PM', 'name': 'Philip Morris International Inc.'},
{'ticker': 'GE', 'name': 'General Electric Company'},
{'ticker': 'CAT', 'name': 'Caterpillar Inc.'},
{'ticker': 'NOW', 'name': 'ServiceNow, Inc.'},
{'ticker': 'SPGI', 'name': 'S&P Global Inc.'},
{'ticker': 'GS', 'name': 'The Goldman Sachs Group, Inc.'},
{'ticker': 'BLK', 'name': 'BlackRock, Inc.'},
{'ticker': 'AXP', 'name': 'American Express Company'},
{'ticker': 'ISRG', 'name': 'Intuitive Surgical, Inc.'},
{'ticker': 'PLTR', 'name': 'Palantir Technologies Inc.'},
{'ticker': 'UBER', 'name': 'Uber Technologies, Inc.'},
{'ticker': 'SHOP', 'name': 'Shopify Inc.'},
{'ticker': 'SQ', 'name': 'Block, Inc.'},
{'ticker': 'PYPL', 'name': 'PayPal Holdings, Inc.'},
{'ticker': 'DIS', 'name': 'The Walt Disney Company'},
{'ticker': 'NKE', 'name': 'NIKE, Inc.'},
{'ticker': 'SBUX', 'name': 'Starbucks Corporation'},
{'ticker': 'PFE', 'name': 'Pfizer Inc.'},
{'ticker': 'BA', 'name': 'The Boeing Company'},
{'ticker': 'F', 'name': 'Ford Motor Company'},
{'ticker': 'GM', 'name': 'General Motors Company'},
{'ticker': 'T', 'name': 'AT&T Inc.'},
{'ticker': 'VZ', 'name': 'Verizon Communications Inc.'},
{'ticker': 'INTC', 'name': 'Intel Corporation'},
{'ticker': 'MU', 'name': 'Micron Technology, Inc.'},
{'ticker': 'LRCX', 'name': 'Lam Research Corporation'},
{'ticker': 'KLAC', 'name': 'KLA Corporation'},
{'ticker': 'ADI', 'name': 'Analog Devices, Inc.'},
{'ticker': 'PANW', 'name': 'Palo Alto Networks, Inc.'},
{'ticker': 'CRWD', 'name': 'CrowdStrike Holdings, Inc.'},
{'ticker': 'SNOW', 'name': 'Snowflake Inc.'},
{'ticker': 'MDB', 'name': 'MongoDB, Inc.'},
{'ticker': 'DDOG', 'name': 'Datadog, Inc.'},
{'ticker': 'ZS', 'name': 'Zscaler, Inc.'},
{'ticker': 'RIVN', 'name': 'Rivian Automotive, Inc.'},
{'ticker': 'LCID', 'name': 'Lucid Group, Inc.'},
{'ticker': 'COIN', 'name': 'Coinbase Global, Inc.'},
{'ticker': 'HOOD', 'name': 'Robinhood Markets, Inc.'},
{'ticker': 'SOFI', 'name': 'SoFi Technologies, Inc.'},
{'ticker': 'ROKU', 'name': 'Roku, Inc.'},
{'ticker': 'DOCU', 'name': 'DocuSign, Inc.'},
{'ticker': 'ZM', 'name': 'Zoom Communications, Inc.'},
{'ticker': 'SPOT', 'name': 'Spotify Technology S.A.'},
{'ticker': 'ETSY', 'name': 'Etsy, Inc.'},
{'ticker': 'EBAY', 'name': 'eBay Inc.'},
{'ticker': 'BABA', 'name': 'Alibaba Group Holding Limited'},
{'ticker': 'JD', 'name': 'JD.com, Inc.'},
{'ticker': 'BIDU', 'name': 'Baidu, Inc.'},
{'ticker': 'SONY', 'name': 'Sony Group Corporation'},
{'ticker': 'SAP', 'name': 'SAP SE'},
{'ticker': 'ASML', 'name': 'ASML Holding N.V.'},
{'ticker': 'ARM', 'name': 'Arm Holdings plc'},
{'ticker': 'TM', 'name': 'Toyota Motor Corporation'},
{'ticker': 'SON', 'name': 'Sonoco Products Company'},
{'ticker': 'DE', 'name': 'Deere & Company'},
{'ticker': 'RTX', 'name': 'RTX Corporation'},
{'ticker': 'LMT', 'name': 'Lockheed Martin Corporation'},
{'ticker': 'HON', 'name': 'Honeywell International Inc.'},
{'ticker': 'UPS', 'name': 'United Parcel Service, Inc.'},
{'ticker': 'FDX', 'name': 'FedEx Corporation'},
{'ticker': 'UAL', 'name': 'United Airlines Holdings, Inc.'},
{'ticker': 'DAL', 'name': 'Delta Air Lines, Inc.'},
{'ticker': 'MAR', 'name': 'Marriott International, Inc.'},
{'ticker': 'BKNG', 'name': 'Booking Holdings Inc.'},
{'ticker': 'EXPE', 'name': 'Expedia Group, Inc.'},
{'ticker': 'CVS', 'name': 'CVS Health Corporation'},
{'ticker': 'CI', 'name': 'The Cigna Group'},
{'ticker': 'MDT', 'name': 'Medtronic plc'},
{'ticker': 'SYK', 'name': 'Stryker Corporation'},
{'ticker': 'GILD', 'name': 'Gilead Sciences, Inc.'},
{'ticker': 'REGN', 'name': 'Regeneron Pharmaceuticals, Inc.'},
{'ticker': 'VRTX', 'name': 'Vertex Pharmaceuticals Incorporated'},
{'ticker': 'CVNA', 'name': 'Carvana Co.'},
{'ticker': 'DKNG', 'name': 'DraftKings Inc.'},
{'ticker': 'RBLX', 'name': 'Roblox Corporation'},
{'ticker': 'EA', 'name': 'Electronic Arts Inc.'},
{'ticker': 'TTWO', 'name': 'Take-Two Interactive Software, Inc.'}
];
+11 -2
View File
@@ -1,16 +1,20 @@
import 'package:capstone_project/pages/home.dart';
import 'package:capstone_project/pages/settings.dart';
import 'package:capstone_project/pages/stock_dashboard.dart';
import 'package:capstone_project/providers/settings.dart';
import 'package:capstone_project/providers/watchlist.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
SettingsProvider _settingsProvider = SettingsProvider();
WatchlistProvider _watchlistProvider = WatchlistProvider();
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await _settingsProvider.load();
await _watchlistProvider.load();
runApp(const MyApp());
}
@@ -24,10 +28,14 @@ class MyApp extends StatelessWidget {
return MultiProvider(
providers: [
ChangeNotifierProvider.value(value: _settingsProvider),
ChangeNotifierProvider.value(value: _watchlistProvider),
],
child: ShadcnApp.router(
scaling: const AdaptiveScaling(0.9),
theme: ThemeData(
colorScheme: ColorSchemes.darkRose
colorScheme: ColorSchemes.darkSlate,
density: Density.spaciousDensity,
radius: 0.5,
),
routerConfig: _routerConfig,
),
@@ -39,5 +47,6 @@ GoRouter _routerConfig = GoRouter(
routes: [
HomePage.route,
SettingsPage.route,
StockDashboardPage.route,
]
);
);
+120
View File
@@ -0,0 +1,120 @@
import "package:capstone_project/utils/agrigator.dart";
// raised when deserializing a signal payload that predates the direction/impact
// rework. the loader catches this and skips the signal.
class LegacySignalException implements Exception {
final String message;
LegacySignalException(this.message);
@override
String toString() => "LegacySignalException: $message";
}
class EventSignal {
final String eventId;
final String eventSummary;
// positive | negative | neutral
final String direction;
// forecasting | reactive
final String nature;
// likelihood the event is real / actually happened as reported (0..1)
final double probability;
// magnitude of expected immediate price reaction, assuming the event is real (0..1)
final double impact;
final String rationale;
final List<FeedItem> articles;
final DateTime createdAt;
EventSignal({
required this.eventId,
required this.eventSummary,
required this.direction,
this.nature = "reactive",
required this.probability,
required this.impact,
required this.rationale,
required this.articles,
DateTime? createdAt,
}) : createdAt = createdAt ?? DateTime.now();
factory EventSignal.fromJson(
Map<String, dynamic> json,
List<FeedItem> articles, {
String? eventIdOverride,
}) {
// legacy schema discriminator — old signals had `signal` + `confidence`
// and no `impact`/`direction`. those numbers meant something different
// (or nothing) so we refuse to load them instead of silently remapping.
final hasImpact = json.containsKey("impact");
final hasDirection = json.containsKey("direction");
if (!hasImpact || !hasDirection) {
throw LegacySignalException(
"payload missing impact/direction — incompatable with current schema",
);
}
return EventSignal(
eventId: eventIdOverride ?? (json["event_id"] ?? "").toString(),
eventSummary: (json["event_summary"] ?? "").toString(),
direction: _normalizeDirection((json["direction"] ?? "").toString()),
nature: _normalizeNature((json["nature"] ?? "").toString()),
probability: _normalizeScore(json["probability"]),
impact: _normalizeScore(json["impact"]),
rationale: (json["rationale"] ?? "").toString(),
articles: articles,
createdAt: DateTime.tryParse((json["created_at"] ?? "").toString()),
);
}
Map<String, dynamic> toJson() {
return {
"event_id": eventId,
"event_summary": eventSummary,
"direction": direction,
"nature": nature,
"probability": probability,
"impact": impact,
"rationale": rationale,
"articles": articles.map((article) => article.toJson()).toList(),
"created_at": createdAt.toIso8601String(),
};
}
static String _normalizeNature(String value) {
return value.trim().toLowerCase() == "forecasting" ? "forecasting" : "reactive";
}
static String _normalizeDirection(String value) {
switch (value.trim().toLowerCase()) {
case "positive":
return "positive";
case "negative":
return "negative";
default:
return "neutral";
}
}
static double _normalizeScore(dynamic value) {
if (value is num) {
return value.toDouble().clamp(0.0, 1.0);
}
if (value is String) {
final parsed = double.tryParse(value);
if (parsed != null) {
return parsed.clamp(0.0, 1.0);
}
}
return 0.0;
}
}
+61
View File
@@ -0,0 +1,61 @@
import "package:capstone_project/models/event_signal.dart";
import "package:capstone_project/utils/agrigator.dart";
class WatchedStock {
final String ticker;
final String companyName;
EventSignal? latestSignal;
List<EventSignal> signalHistory;
WatchedStock({
required this.ticker,
required this.companyName,
this.latestSignal,
List<EventSignal>? signalHistory,
}) : signalHistory = signalHistory ?? [];
factory WatchedStock.fromJson(Map<String, dynamic> json) {
final signalHistoryJson = (json["signalHistory"] as List? ?? [])
.whereType<Map<String, dynamic>>()
.map(_eventSignalFromStoredJson)
.whereType<EventSignal>()
.toList();
final latestSignalJson = json["latestSignal"];
final latest = latestSignalJson is Map<String, dynamic>
? _eventSignalFromStoredJson(latestSignalJson)
: null;
return WatchedStock(
ticker: (json["ticker"] ?? "").toString(),
companyName: (json["companyName"] ?? "").toString(),
latestSignal: latest ?? (signalHistoryJson.isNotEmpty ? signalHistoryJson.first : null),
signalHistory: signalHistoryJson,
);
}
Map<String, dynamic> toJson() {
return {
"ticker": ticker,
"companyName": companyName,
"latestSignal": latestSignal?.toJson(),
"signalHistory": signalHistory.map((signal) => signal.toJson()).toList(),
};
}
// returns null (and logs) when the stored payload is from the pre-rework
// schema — we'd rather drop it than invent impact/direction values.
static EventSignal? _eventSignalFromStoredJson(Map<String, dynamic> json) {
final articles = (json["articles"] as List? ?? [])
.whereType<Map<String, dynamic>>()
.map(FeedItem.fromJson)
.toList();
try {
return EventSignal.fromJson(json, articles);
} on LegacySignalException catch (e) {
print("[watched_stock] dropping legacy signal: ${e.message}");
return null;
}
}
}
+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!,
],
],
);
}
}
+7 -191
View File
@@ -1,115 +1,14 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uid/uid.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:io';
class SettingsProvider extends ChangeNotifier {
String _OPENAI_API_KEY = "";
String get openAIApiKey => _OPENAI_API_KEY;
List<Feed> _feeds = [
// Major Business News
Feed(
title: "Business Insider",
url: "https://feeds.businessinsider.com/custom/all"
),
Feed(
title: "BBC Business",
url: "https://feeds.bbci.co.uk/news/business/rss.xml"
),
Feed(
title: "Reuters Business",
url: "https://www.reutersagency.com/feed/?taxonomy=best-topics&post_type=best"
),
Feed(
title: "Financial Times",
url: "https://www.ft.com/?format=rss"
),
Feed(
title: "Bloomberg",
url: "https://feeds.bloomberg.com/markets/news.rss"
),
// Market-Specific
Feed(
title: "MarketWatch",
url: "https://feeds.marketwatch.com/marketwatch/topstories/"
),
Feed(
title: "Seeking Alpha",
url: "https://seekingalpha.com/feed.xml"
),
Feed(
title: "Yahoo Finance",
url: "https://finance.yahoo.com/news/rssindex"
),
Feed(
title: "CNBC",
url: "https://www.cnbc.com/id/100003114/device/rss/rss.html"
),
Feed(
title: "The Wall Street Journal",
url: "https://feeds.a.dj.com/rss/RSSMarketsMain.xml"
),
// Tech Business
Feed(
title: "TechCrunch",
url: "https://techcrunch.com/feed/"
),
Feed(
title: "The Verge",
url: "https://www.theverge.com/rss/index.xml"
),
Feed(
title: "Ars Technica",
url: "https://feeds.arstechnica.com/arstechnica/index"
),
// Company News
Feed(
title: "Fortune",
url: "https://fortune.com/feed"
),
Feed(
title: "Forbes Business",
url: "https://www.forbes.com/business/feed/"
),
Feed(
title: "Inc Magazine",
url: "https://www.inc.com/rss"
),
// Industry-Specific
Feed(
title: "Retail Dive",
url: "https://www.retaildive.com/feeds/news/"
),
Feed(
title: "Manufacturing Dive",
url: "https://www.manufacturingdive.com/feeds/news/"
),
Feed(
title: "Banking Dive",
url: "https://www.bankingdive.com/feeds/news/"
),
// Economic News
Feed(
title: "The Economist",
url: "https://www.economist.com/finance-and-economics/rss.xml"
),
Feed(
title: "Federal Reserve News",
url: "https://www.federalreserve.gov/feeds/press_all.xml"
),
]; // List of rss feed URLs
List<Feed> get feeds => List.unmodifiable(_feeds);
String _openRouterApiKey = "";
String get openRouterApiKey => _openRouterApiKey;
late String _applicationStorageLocation;
String get applicationStorageLocation => _applicationStorageLocation;
@@ -119,42 +18,13 @@ class SettingsProvider extends ChangeNotifier {
}
Future<void> load() async {
// get documents directory dynamicly
final Directory appDocDir = await getApplicationDocumentsDirectory();
_applicationStorageLocation = appDocDir.path;
SharedPreferences prefs = await SharedPreferences.getInstance();
_OPENAI_API_KEY = prefs.getString('openai_api_key') ?? _OPENAI_API_KEY;
List<String>? feedStrings = prefs.getStringList('feeds');
if (feedStrings != null) {
_feeds = feedStrings.map((fStr) {
try {
return Feed.fromJson(jsonDecode(fStr));
} catch (e) {
// old format, parse manually
Map<String, dynamic> json = {};
fStr.substring(1, fStr.length - 1).split(', ').forEach((pair) {
List<String> keyValue = pair.split(': ');
String key = keyValue[0];
String value = keyValue[1];
// convert bool strings to actual bools
if (value == 'true') {
json[key] = true;
} else if (value == 'false') {
json[key] = false;
} else {
json[key] = value;
}
});
return Feed.fromJson(json);
}
}).toList();
}
_applicationStorageLocation = prefs.getString('application_storage_location') ?? applicationStorageLocation;
_openRouterApiKey = prefs.getString('openrouter_api_key') ?? prefs.getString('openai_api_key') ?? _openRouterApiKey;
_applicationStorageLocation = prefs.getString('application_storage_location') ?? _applicationStorageLocation;
notifyListeners();
}
@@ -162,42 +32,12 @@ class SettingsProvider extends ChangeNotifier {
Future<void> save() async {
SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setString('openai_api_key', _OPENAI_API_KEY);
await prefs.setStringList('feeds', _feeds.map((f) => jsonEncode(f.toJson())).toList());
await prefs.setString('openrouter_api_key', _openRouterApiKey);
await prefs.setString('application_storage_location', _applicationStorageLocation);
}
void setOpenAIApiKey(String key) {
_OPENAI_API_KEY = key;
save();
notifyListeners();
}
void addFeed(Feed feed) {
_feeds.add(feed);
save();
notifyListeners();
}
Feed updateFeed(int id, {String? title, String? url, bool? enabled}) {
for (int i = 0; i < _feeds.length; i++) {
if (_feeds[i].id == id) {
Feed updatedFeed = Feed(
url: url ?? _feeds[i].url,
title: title ?? _feeds[i].title,
enabled: enabled ?? _feeds[i].enabled,
);
_feeds[i] = updatedFeed;
save();
notifyListeners();
return updatedFeed;
}
}
throw Exception("Feed with id $id not found");
}
void removeFeed(Feed feed) {
_feeds.removeWhere((f) => f.url == feed.url);
void setOpenRouterApiKey(String key) {
_openRouterApiKey = key;
save();
notifyListeners();
}
@@ -209,27 +49,3 @@ class SettingsProvider extends ChangeNotifier {
}
}
class Feed {
final String url;
final String title;
final bool enabled;
Feed({required this.url, required this.title, this.enabled = true});
Feed.fromJson(Map<String, dynamic> json)
: url = json['url'],
title = json['title'],
enabled = json['enabled'] ?? true;
Map<String, dynamic> toJson() => {
'url': url,
'title': title,
'enabled': enabled,
};
int get id {
return url.hashCode ^ title.hashCode;
}
}
+91
View File
@@ -0,0 +1,91 @@
import 'dart:convert';
import 'package:capstone_project/models/event_signal.dart';
import 'package:capstone_project/models/watched_stock.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
class WatchlistProvider extends ChangeNotifier {
static const String _storageKey = 'watched_stocks';
List<WatchedStock> _stocks = [];
List<WatchedStock> get stocks => List.unmodifiable(_stocks);
static WatchlistProvider of(BuildContext context) {
return Provider.of<WatchlistProvider>(context, listen: false);
}
Future<void> load() async {
final prefs = await SharedPreferences.getInstance();
final stockStrings = prefs.getStringList(_storageKey) ?? [];
_stocks = stockStrings.map((stockString) {
return WatchedStock.fromJson(jsonDecode(stockString));
}).toList();
notifyListeners();
}
Future<void> save() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(
_storageKey,
_stocks.map((stock) => jsonEncode(stock.toJson())).toList(),
);
}
Future<void> addStock(String ticker, String companyName) async {
final normalizedTicker = ticker.trim().toUpperCase();
final normalizedCompanyName = companyName.trim();
if (normalizedTicker.isEmpty || normalizedCompanyName.isEmpty) {
return;
}
final existingIndex = _stocks.indexWhere((stock) => stock.ticker == normalizedTicker);
if (existingIndex != -1) {
_stocks[existingIndex] = WatchedStock(
ticker: normalizedTicker,
companyName: normalizedCompanyName,
latestSignal: _stocks[existingIndex].latestSignal,
signalHistory: _stocks[existingIndex].signalHistory,
);
} else {
_stocks.add(
WatchedStock(
ticker: normalizedTicker,
companyName: normalizedCompanyName,
),
);
}
await save();
notifyListeners();
}
Future<void> removeStock(String ticker) async {
_stocks.removeWhere((stock) => stock.ticker == ticker.trim().toUpperCase());
await save();
notifyListeners();
}
Future<void> updateSignals(String ticker, List<EventSignal> signals) async {
final normalizedTicker = ticker.trim().toUpperCase();
final stockIndex = _stocks.indexWhere((stock) => stock.ticker == normalizedTicker);
if (stockIndex == -1) {
return;
}
_stocks[stockIndex] = WatchedStock(
ticker: _stocks[stockIndex].ticker,
companyName: _stocks[stockIndex].companyName,
latestSignal: signals.isNotEmpty ? signals.first : null,
signalHistory: List<EventSignal>.from(signals),
);
await save();
notifyListeners();
}
}
+117
View File
@@ -0,0 +1,117 @@
import 'dart:convert';
import 'package:capstone_project/utils/agrigator.dart';
import 'package:http/http.dart' as http;
const _baseUrl = 'http://duriin.imbenji.net';
// pair of (article, semantic distance from the query / seed). distance is
// null when the underlying endpoint doesnt return one.
class SimilarHit {
final FeedItem item;
final double? distance;
SimilarHit({required this.item, this.distance});
}
class DuriinService {
Future<List<FeedItem>> search({
required String ticker,
required String companyName,
required DateTime from,
required DateTime to,
int limit = 100,
}) async {
final fromStr = from.toUtc().toIso8601String();
final toStr = to.toUtc().toIso8601String();
// build the keyword set: the ticker itself plus the company brand name
// (first token of the registered name — "NVIDIA Corporation" -> "NVIDIA",
// "Apple Inc" -> "Apple"). the full legal name rarely appears verbatim in
// articles so matching on it would miss most hits.
final keywords = <String>{};
final t = ticker.trim();
if (t.isNotEmpty) keywords.add(t);
final brand = companyName.trim().split(RegExp(r"\s+")).firstOrNull ?? "";
if (brand.isNotEmpty) keywords.add(brand);
final hits = await _fetchHits(queryParams: {
'keyword': keywords.toList(),
'keyword_mode': 'or',
'from': fromStr,
'to': toStr,
'limit': '$limit',
'order': 'newest',
});
final items = hits.map((h) => h.item).toList();
print('Duriin: ${items.length} articles for $ticker / $companyName (kw=${keywords.join(",")})');
return items;
}
// neighbours of a given article via the vector index. used by the event
// clusterer to build real clusters instead of arbitrary time slices.
Future<List<SimilarHit>> findSimilar(int articleId, {int limit = 25}) async {
return _fetchHits(queryParams: {
'similar_to_article': '$articleId',
'limit': '$limit',
});
}
Future<List<SimilarHit>> _fetchHits({required Map<String, dynamic> queryParams}) async {
final uri = Uri.parse(_baseUrl).replace(
path: '/articles',
queryParameters: queryParams,
);
try {
final response = await http.get(uri);
if (response.statusCode != 200) {
print('Duriin: HTTP ${response.statusCode} for $uri');
return [];
}
final body = response.body.trim();
if (body.isEmpty) return [];
final list = jsonDecode(body) as List<dynamic>;
return list.map((raw) {
final m = raw as Map<String, dynamic>;
final id = m['id'] is int
? m['id'] as int
: int.tryParse('${m['id']}');
final item = FeedItem(
id: id,
title: (m['title'] ?? '').toString(),
description: (m['description'] ?? '').toString(),
content: (m['content'] ?? '').toString(),
link: (m['url'] ?? '').toString(),
source: (m['source'] ?? '').toString().isNotEmpty ? m['source'].toString() : null,
pubDate: m['pub_date'] != null ? DateTime.tryParse(m['pub_date'].toString()) : null,
);
final distRaw = m['distance'];
final distance = distRaw is num ? distRaw.toDouble() : null;
return SimilarHit(item: item, distance: distance);
}).where((h) => h.item.title.isNotEmpty && h.item.link.isNotEmpty).toList();
} catch (e) {
print('Duriin fetch error: $e');
return [];
}
}
}
+19
View File
@@ -0,0 +1,19 @@
import 'package:capstone_project/services/duriin_service.dart';
import 'package:capstone_project/utils/agrigator.dart';
class NewsSearchService {
final _duriin = DuriinService();
Future<List<FeedItem>> search({
required String ticker,
required String companyName,
required DateTime from,
required DateTime to,
}) => _duriin.search(
ticker: ticker,
companyName: companyName,
from: from,
to: to,
);
}
+82
View File
@@ -0,0 +1,82 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class StockPricePoint {
final DateTime date;
final double close;
const StockPricePoint({required this.date, required this.close});
}
class StockPriceService {
Future<List<StockPricePoint>> fetchPriceHistory(
String ticker, {
String range = '1mo',
String interval = '1d',
}) async {
final normalizedTicker = ticker.trim().toUpperCase();
final uri = Uri.parse(
'https://query1.finance.yahoo.com/v8/finance/chart/$normalizedTicker?interval=$interval&range=$range',
);
final response = await http.get(uri, headers: {
'User-Agent': 'Mozilla/5.0',
'Accept': 'application/json',
});
if (response.statusCode != 200) {
throw Exception('Failed to fetch price history for $normalizedTicker: ${response.statusCode}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
final chart = json['chart'] as Map<String, dynamic>?;
final results = chart?['result'] as List?;
if (results == null || results.isEmpty) {
throw Exception('No price history returned for $normalizedTicker');
}
final result = results.first as Map<String, dynamic>;
final timestamps = (result['timestamp'] as List?) ?? const [];
final indicators = result['indicators'] as Map<String, dynamic>?;
final adjCloseSeries = ((indicators?['adjclose'] as List?) ?? const [])
.cast<Map<String, dynamic>?>();
final quoteSeries = ((indicators?['quote'] as List?) ?? const [])
.cast<Map<String, dynamic>?>();
final adjCloseEntry = adjCloseSeries.isNotEmpty ? adjCloseSeries.first : null;
final quoteEntry = quoteSeries.isNotEmpty ? quoteSeries.first : null;
final adjCloseValues = adjCloseEntry == null ? null : adjCloseEntry['adjclose'];
final quoteCloseValues = quoteEntry == null ? null : quoteEntry['close'];
final closes = (adjCloseValues as List?) ?? (quoteCloseValues as List?) ?? const [];
final points = <StockPricePoint>[];
final itemCount = timestamps.length < closes.length ? timestamps.length : closes.length;
for (int i = 0; i < itemCount; i++) {
final timestamp = timestamps[i];
final close = closes[i];
if (timestamp is! num || close is! num) {
continue;
}
points.add(
StockPricePoint(
date: DateTime.fromMillisecondsSinceEpoch(
timestamp.toInt() * 1000,
isUtc: true,
).toLocal(),
close: close.toDouble(),
),
);
}
if (points.isEmpty) {
throw Exception('No valid price points returned for $normalizedTicker');
}
return points;
}
}
+36 -244
View File
@@ -1,268 +1,60 @@
import 'dart:math';
import 'package:http/http.dart' as http;
import 'package:xml/xml.dart';
import 'openai.dart';
const String KEYWORDS = "Business news corporate earnings revenue profit stock market trading equity shares NYSE NASDAQ stock prices quarterly results annual reports CEO announcements executive leadership management changes board directors company strategy mergers acquisitions takeovers buyouts partnerships joint ventures business deals IPO initial public offerings venture capital funding investment rounds valuation startup unicorn enterprise technology product launches innovation R&D research development market expansion international business global markets trade agreements tariffs import export supply chain logistics manufacturing production operations facilities factories plants workforce hiring layoffs restructuring downsizing labor unions strikes employee relations workplace compensation benefits corporate governance shareholder activism proxy fights dividends stock buybacks analyst ratings price targets market capitalization revenue growth profit margins EBITDA cash flow debt financing credit ratings bonds corporate strategy competitive advantage market share industry trends sector analysis retail consumer goods e-commerce technology software hardware semiconductors pharmaceuticals biotech healthcare energy oil gas renewables automotive electric vehicles aerospace defense banking financial services insurance real estate construction infrastructure telecommunications media entertainment streaming gaming hospitality travel transportation logistics shipping airlines regulatory compliance antitrust competition policy lawsuits litigation settlements data breaches cybersecurity intellectual property patents trademarks brand value customer acquisition market positioning business models revenue streams profitability sustainability ESG environmental social governance";
List<double>? KEYWORD_EMBEDDINGS;
class FeedItem {
final int? id;
final String title;
final String description;
// the extracted article body — may be empty for older rows or sources
// that havent been backfilled yet.
final String content;
final String link;
List<double>? embedding;
final String? source;
final DateTime? pubDate;
FeedItem({
this.id,
required this.title,
required this.description,
this.content = "",
required this.link,
this.embedding,
this.source,
this.pubDate,
});
@override
String toString() {
return "FeedItem(title: $title, link: $link)";
}
String toString() => 'FeedItem(title: $title, link: $link)';
FeedItem.fromJson(Map<String, dynamic> json)
: title = json["title"],
description = json["description"],
link = json["link"],
embedding = json["embedding"] != null
? (json["embedding"] as List).map<double>((e) => (e as num).toDouble()).toList()
: null;
: id = json['id'] is int ? json['id'] as int : int.tryParse('${json['id']}'),
title = json['title'] ?? '',
description = json['description'] ?? '',
content = (json['content'] ?? '').toString(),
link = json['link'] ?? '',
source = json['source'],
pubDate = json['pub_date'] != null ? DateTime.tryParse(json['pub_date']) : null;
Map<String, dynamic> toJson() {
return {
"title": title,
"description": description,
"link": link,
if (embedding != null) "embedding": embedding,
if (id != null) 'id': id,
'title': title,
'description': description,
if (content.isNotEmpty) 'content': content,
'link': link,
if (source != null) 'source': source,
if (pubDate != null) 'pub_date': pubDate!.toIso8601String(),
};
}
}
List<FeedItem> parseRssFeed(String rssXml) {
final document = XmlDocument.parse(rssXml);
// find items in the RSS structre
final items = document.findAllElements("item");
return items.map((item) {
final title = item.findElements("title").firstOrNull?.innerText.trim() ?? "Untitled";
final link = item.findElements("link").firstOrNull?.innerText ?? "";
final description = item.findElements("description").firstOrNull?.innerText.trim() ?? "";
return FeedItem(
title: title,
link: link,
description: description,
);
}).toList();
}
List<FeedItem> parseAtomFeed(String atomXml) {
final document = XmlDocument.parse(atomXml);
// find entrys in atom feed
final entries = document.findAllElements("entry");
return entries.map((entry) {
final title = entry.findElements("title").firstOrNull?.innerText.trim() ?? "Untitled";
final linkElement = entry.findElements("link").firstOrNull;
final link = linkElement?.getAttribute("href") ?? "";
final summary = entry.findElements("summary").firstOrNull?.innerText.trim();
final content = entry.findElements("content").firstOrNull?.innerText.trim();
final description = (summary ?? content ?? "").trim();
return FeedItem(
title: title,
link: link,
description: description,
);
}).toList();
}
List<FeedItem> parseFeed(String feedXml) {
final document = XmlDocument.parse(feedXml);
// Check if it's an Atom feed
if (document.findAllElements('feed').isNotEmpty) {
return parseAtomFeed(feedXml);
}
// Check if it's an RSS feed
if (document.findAllElements('rss').isNotEmpty ||
document.findAllElements('channel').isNotEmpty) {
return parseRssFeed(feedXml);
}
// Unknown feed format
throw FormatException('Unknown feed format. Expected RSS or Atom.');
}
Future<List<FeedItem>> fetchFeed(Uri feedUri) async {
final response = await http.get(feedUri);
if (response.statusCode != 200) {
throw Exception("Failed to fetch feed: ${response.statusCode}");
}
// parse the XML response
return parseFeed(response.body);
}
Future<List<FeedItem>> fetchFeeds(List<Uri> feedUris) async {
List<FeedItem> allItems = [];
final results = await Future.wait(
feedUris.map((uri) => fetchFeed(uri).catchError((e) {
print("Error fetching feed $uri: $e");
return <FeedItem>[];
}))
);
for (final items in results) {
allItems.addAll(items);
}
return allItems;
}
// generete embeddng for a feed item
Future<void> generateEmbedding(FeedItem item, String apiKey) async {
final openai = OpenAI(apiKey: apiKey);
// median pub date of a cluster — falls back to now if none have dates
DateTime medianPubDate(List<FeedItem> articles) {
final dates = articles
.where((a) => a.pubDate != null)
.map((a) => a.pubDate!)
.toList()
..sort();
// combine tittle and descriptin
final textToEmbed = "${item.title} ${item.description}";
try {
final response = await openai.embeddings.create(
model: "text-embedding-3-small",
input: textToEmbed,
);
if (response.data.isNotEmpty) {
item.embedding = response.data.first.embedding;
}
} catch (e) {
print("Error generatng embedding: $e");
} finally {
openai.dispose();
}
if (dates.isEmpty) return DateTime.now();
return dates[dates.length ~/ 2];
}
// generate embedings for multiple feed items
Future<void> generateEmbeddings(List<FeedItem> items, String apiKey) async {
await Future.wait(
items.map((item) => generateEmbedding(item, apiKey))
);
}
Future<void> generateKeywordEmbeddings(String apiKey) async {
if (KEYWORD_EMBEDDINGS != null) {
return; // already generated
}
final openai = OpenAI(apiKey: apiKey);
try {
final response = await openai.embeddings.create(
model: "text-embedding-3-small",
input: KEYWORDS,
);
if (response.data.isNotEmpty) {
KEYWORD_EMBEDDINGS = response.data.first.embedding;
}
} catch (e) {
print("Error generating keyword embeddings: $e");
} finally {
openai.dispose();
}
}
bool isFeedItemRelevant(FeedItem item, [double threshold = 0.25]) {
if (item.embedding == null || KEYWORD_EMBEDDINGS == null) {
throw Exception("Embeddings not available for comparison.");
}
double similarity = cosineSimilarity(item.embedding!, KEYWORD_EMBEDDINGS!);
return similarity >= threshold;
}
double cosineSimilarity(List<double> vecA, List<double> vecB) {
if (vecA.length != vecB.length) {
throw ArgumentError("Vectors must be of the same length");
}
double dotProduct = 0.0;
double magnitudeA = 0.0;
double magnitudeB = 0.0;
for (int i = 0; i < vecA.length; i++) {
dotProduct += vecA[i] * vecB[i];
magnitudeA += vecA[i] * vecA[i];
magnitudeB += vecB[i] * vecB[i];
}
if (magnitudeA == 0 || magnitudeB == 0) {
return 0.0;
}
return dotProduct / (sqrt(magnitudeA) * sqrt(magnitudeB));
}
List<List<FeedItem>> groupFeedItemsByEvent(List<FeedItem> items, [double similarityThreshold = 0.7]) {
// Track which group each item belongs to and with what similarity
Map<int, ({int groupIndex, double similarity})> itemGrouping = {};
List<List<FeedItem>> groupedItems = [];
for (int i = 0; i < items.length; i++) {
// Create a new group with item i as the anchor
List<FeedItem> currentGroup = [items[i]];
int currentGroupIndex = groupedItems.length;
// item i belongs to its own group with similarity 1.0
itemGrouping[i] = (groupIndex: currentGroupIndex, similarity: 1.0);
// Check all later items
for (int j = i + 1; j < items.length; j++) {
double similarity = cosineSimilarity(
items[i].embedding!,
items[j].embedding!,
);
if (similarity >= similarityThreshold) {
// Check if j should join this group
if (!itemGrouping.containsKey(j)) {
// j hasn't been grouped yet, add it
currentGroup.add(items[j]);
itemGrouping[j] = (groupIndex: currentGroupIndex, similarity: similarity);
} else if (similarity > itemGrouping[j]!.similarity) {
// j is in another group but this is a better match
// Remove from old group
int oldGroupIndex = itemGrouping[j]!.groupIndex;
groupedItems[oldGroupIndex].remove(items[j]);
// Add to this group
currentGroup.add(items[j]);
itemGrouping[j] = (groupIndex: currentGroupIndex, similarity: similarity);
}
}
}
groupedItems.add(currentGroup);
}
// Filter out empty groups (items may have been moved out)
return groupedItems.where((group) => group.isNotEmpty).toList();
}
+154
View File
@@ -0,0 +1,154 @@
import "package:capstone_project/services/duriin_service.dart";
import "package:capstone_project/utils/agrigator.dart";
// one event cluster built from a seed article + its semantic neighbours.
// distances are measured from the seed; the seed itself has distance 0.
class EventCluster {
final FeedItem seed;
final List<FeedItem> articles;
// article.id -> distance from seed. missing id / missing distance means
// we dont have a number for it (falls back to nulls in the stats).
final Map<int, double> distancesFromSeed;
EventCluster({
required this.seed,
required this.articles,
required this.distancesFromSeed,
});
// convenience: summary stats over the (known) distances in this cluster.
// returns nulls if we have no distances to report (eg a singleton cluster).
({double? min, double? avg, double? max}) distanceStats() {
final vals = distancesFromSeed.values.toList();
if (vals.isEmpty) return (min: null, avg: null, max: null);
double lo = vals.first;
double hi = vals.first;
double sum = 0.0;
for (final v in vals) {
if (v < lo) lo = v;
if (v > hi) hi = v;
sum += v;
}
return (min: lo, avg: sum / vals.length, max: hi);
}
}
class EventClusterer {
final DuriinService _duriin;
// neighbours whose distance from the seed is strictly greater than this
// are dropped as off-topic. calibrated against observed api distances:
// genuinely-same-event pairs land around 0.580.62, different-event-same-
// topic pairs start around 0.70+. tighten if clusters start merging distinct
// events, loosen if obvious same-event stories end up as singletons.
final double distanceThreshold;
// hard cap on articles per cluster — keeps prompt size predictable
final int maxClusterSize;
// how many neighbours to ask the api for per seed
final int neighbourFetchLimit;
EventClusterer({
DuriinService? duriin,
this.distanceThreshold = 0.60,
this.maxClusterSize = 10,
this.neighbourFetchLimit = 25,
}) : _duriin = duriin ?? DuriinService();
Future<List<EventCluster>> cluster(List<FeedItem> articles) async {
if (articles.isEmpty) return [];
// index by id for fast membership checks when neighbours come back
final byId = <int, FeedItem>{};
final withoutId = <FeedItem>[];
for (final a in articles) {
if (a.id != null) {
byId[a.id!] = a;
} else {
withoutId.add(a);
}
}
// work through newest first so the first signal surfaced is the freshest
final queue = byId.values.toList()
..sort((a, b) {
final da = a.pubDate ?? DateTime.fromMillisecondsSinceEpoch(0);
final db = b.pubDate ?? DateTime.fromMillisecondsSinceEpoch(0);
return db.compareTo(da);
});
final clustered = <int>{};
final clusters = <EventCluster>[];
for (final seed in queue) {
if (clustered.contains(seed.id)) continue;
final neighbours = await _duriin.findSimilar(
seed.id!,
limit: neighbourFetchLimit,
);
// keep only neighbours we actually fetched (same ticker / window)
// and that are close enough to count as the same event.
final members = <FeedItem>[seed];
final distances = <int, double>{};
// dedupe just in case the api returns the seed in its own neighbour list
final memberIds = <int>{seed.id!};
for (final hit in neighbours) {
final nid = hit.item.id;
if (nid == null) continue;
if (nid == seed.id) continue;
if (memberIds.contains(nid)) continue;
final inWindow = byId[nid];
if (inWindow == null) continue;
final d = hit.distance;
if (d == null) continue;
if (d > distanceThreshold) continue;
members.add(inWindow);
distances[nid] = d;
memberIds.add(nid);
if (members.length >= maxClusterSize) break;
}
for (final id in memberIds) {
clustered.add(id);
}
clusters.add(EventCluster(
seed: seed,
articles: members,
distancesFromSeed: distances,
));
}
// articles with no id (shouldnt happen post-api-update, but just in case)
// each becomes its own singleton cluster so we never silently drop them.
for (final orphan in withoutId) {
clusters.add(EventCluster(
seed: orphan,
articles: [orphan],
distancesFromSeed: const {},
));
}
return clusters;
}
}
@@ -1,23 +1,19 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
/// OpenAI API client for Dart
class OpenAI {
/// OpenRouter API client for Dart
class OpenRouter {
final String apiKey;
final String baseUrl;
final http.Client _client;
OpenAI({
OpenRouter({
required this.apiKey,
this.baseUrl = 'https://api.openai.com/v1',
this.baseUrl = 'https://openrouter.ai/api/v1',
http.Client? client,
}) : _client = client ?? http.Client();
/// Access to chat completions API
ChatCompletions get chat => ChatCompletions(this);
/// Access to embeddings API
Embeddings get embeddings => Embeddings(this);
void dispose() {
@@ -25,26 +21,19 @@ class OpenAI {
}
}
/// Chat completions API
class ChatCompletions {
final OpenAI _openai;
final OpenRouter _openRouter;
ChatCompletions(this._openai);
ChatCompletions(this._openRouter);
/// Access to completions endpoint
Completions get completions => Completions(_openai);
Completions get completions => Completions(_openRouter);
}
/// Completions endpoint
class Completions {
final OpenAI _openai;
final OpenRouter _openRouter;
Completions(this._openai);
Completions(this._openRouter);
/// Create a chat completion
///
/// If [stream] is true, returns a Stream of ChatCompletionChunk
/// If [stream] is false, returns a single ChatCompletion
Future<dynamic> create({
required String model,
required List<dynamic> messages,
@@ -84,17 +73,17 @@ class Completions {
}
Future<ChatCompletion> _createCompletion(Map<String, dynamic> body) async {
final response = await _openai._client.post(
Uri.parse('${_openai.baseUrl}/chat/completions'),
final response = await _openRouter._client.post(
Uri.parse('${_openRouter.baseUrl}/chat/completions'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ${_openai.apiKey}',
'Authorization': 'Bearer ${_openRouter.apiKey}',
},
body: jsonEncode(body),
);
if (response.statusCode != 200) {
throw OpenAIException(
throw OpenRouterException(
statusCode: response.statusCode,
message: response.body,
);
@@ -107,21 +96,21 @@ class Completions {
Map<String, dynamic> body) async* {
final request = http.Request(
'POST',
Uri.parse('${_openai.baseUrl}/chat/completions'),
Uri.parse('${_openRouter.baseUrl}/chat/completions'),
);
request.headers.addAll({
'Content-Type': 'application/json',
'Authorization': 'Bearer ${_openai.apiKey}',
'Authorization': 'Bearer ${_openRouter.apiKey}',
});
request.body = jsonEncode(body);
final streamedResponse = await _openai._client.send(request);
final streamedResponse = await _openRouter._client.send(request);
if (streamedResponse.statusCode != 200) {
final body = await streamedResponse.stream.bytesToString();
throw OpenAIException(
throw OpenRouterException(
statusCode: streamedResponse.statusCode,
message: body,
);
@@ -133,10 +122,10 @@ class Completions {
await for (final line in stream) {
if (line.isEmpty) continue;
if (line.startsWith(':')) continue; // Skip comments
if (line.startsWith(':')) continue;
if (!line.startsWith('data: ')) continue;
final data = line.substring(6); // Remove 'data: ' prefix
final data = line.substring(6);
if (data == '[DONE]') {
break;
@@ -146,14 +135,12 @@ class Completions {
final json = jsonDecode(data);
yield ChatCompletionChunk.fromJson(json);
} catch (e) {
// Skip malformed chunks
continue;
}
}
}
}
/// Chat message
class ChatMessage {
final String role;
final String content;
@@ -183,7 +170,6 @@ class ChatMessage {
ChatMessage(role: 'assistant', content: content);
}
/// Stream options
class StreamOptions {
final bool includeUsage;
@@ -194,7 +180,6 @@ class StreamOptions {
};
}
/// Chat completion response (non-streaming)
class ChatCompletion {
final String id;
final String object;
@@ -224,7 +209,6 @@ class ChatCompletion {
);
}
/// Chat completion chunk (streaming)
class ChatCompletionChunk {
final String id;
final String object;
@@ -255,7 +239,6 @@ class ChatCompletionChunk {
);
}
/// Choice in non-streaming response
class Choice {
final int index;
final ChatMessage message;
@@ -274,7 +257,6 @@ class Choice {
);
}
/// Choice in streaming response
class ChunkChoice {
final int index;
final Delta? delta;
@@ -293,7 +275,6 @@ class ChunkChoice {
);
}
/// Delta content in streaming chunks
class Delta {
final String? role;
final String? content;
@@ -309,7 +290,6 @@ class Delta {
);
}
/// Token usage information
class Usage {
final int? promptTokens;
final int? completionTokens;
@@ -334,32 +314,29 @@ class Usage {
};
}
/// OpenAI API exception
class OpenAIException implements Exception {
class OpenRouterException implements Exception {
final int statusCode;
final String message;
OpenAIException({
OpenRouterException({
required this.statusCode,
required this.message,
});
@override
String toString() => 'OpenAIException($statusCode): $message';
String toString() => 'OpenRouterException($statusCode): $message';
}
/// Embeddings API
class Embeddings {
final OpenAI _openai;
final OpenRouter _openRouter;
Embeddings(this._openai);
Embeddings(this._openRouter);
/// Create embeddings for input text
Future<EmbeddingResponse> create({
required String model,
required dynamic input, // String or List<String>
required dynamic input,
String? user,
String? encodingFormat, // 'float' or 'base64'
String? encodingFormat,
int? dimensions,
}) async {
final body = {
@@ -370,17 +347,17 @@ class Embeddings {
if (dimensions != null) 'dimensions': dimensions,
};
final response = await _openai._client.post(
Uri.parse('${_openai.baseUrl}/embeddings'),
final response = await _openRouter._client.post(
Uri.parse('${_openRouter.baseUrl}/embeddings'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ${_openai.apiKey}',
'Authorization': 'Bearer ${_openRouter.apiKey}',
},
body: jsonEncode(body),
);
if (response.statusCode != 200) {
throw OpenAIException(
throw OpenRouterException(
statusCode: response.statusCode,
message: response.body,
);
@@ -390,7 +367,6 @@ class Embeddings {
}
}
/// Embedding response
class EmbeddingResponse {
final String object;
final List<Embedding> data;
@@ -406,14 +382,13 @@ class EmbeddingResponse {
factory EmbeddingResponse.fromJson(Map<String, dynamic> json) =>
EmbeddingResponse(
object: json['object'],
data: (json['data'] as List).map((e) => Embedding.fromJson(e)).toList(),
model: json['model'],
object: (json['object'] ?? '').toString(),
data: (json['data'] as List? ?? []).map((e) => Embedding.fromJson(e)).toList(),
model: (json['model'] ?? '').toString(),
usage: json['usage'] != null ? Usage.fromJson(json['usage']) : null,
);
}
/// Individual embedding
class Embedding {
final String object;
final int index;
@@ -426,9 +401,10 @@ class Embedding {
});
factory Embedding.fromJson(Map<String, dynamic> json) => Embedding(
object: json['object'],
index: json['index'],
embedding: (json['embedding'] as List).map<double>((e) => (e as num).toDouble()).toList(),
);
}
object: (json['object'] ?? '').toString(),
index: (json['index'] ?? 0) as int,
embedding: (json['embedding'] as List? ?? [])
.map<double>((e) => (e as num).toDouble())
.toList(),
);
}
+26
View File
@@ -0,0 +1,26 @@
// bucket 0..1 signal scores (probability, impact) into human labels. the
// llm outputs a continuous number but the ui shouldnt pretend that number
// means much beyond a rough band — "12.3%" reads as false precision.
//
// thresholds are deliberately a bit generous at the bottom so that the
// failure-fallback path (0.0) lands clearly in NONE.
enum SignalBucket { none, low, medium, high }
SignalBucket bucketFor(double score) {
if (score < 0.15) return SignalBucket.none;
if (score < 0.40) return SignalBucket.low;
if (score < 0.70) return SignalBucket.medium;
return SignalBucket.high;
}
String bucketLabel(double score) {
switch (bucketFor(score)) {
case SignalBucket.none: return "None";
case SignalBucket.low: return "Low";
case SignalBucket.medium: return "Medium";
case SignalBucket.high: return "High";
}
}
+235
View File
@@ -0,0 +1,235 @@
import "dart:convert";
import "package:capstone_project/models/event_signal.dart";
import "package:capstone_project/utils/agrigator.dart";
import "package:capstone_project/utils/event_clusterer.dart";
import "package:capstone_project/utils/openrouter.dart";
// per-article content cap in the prompt. higher = more context, more tokens.
// tuned so an 8-article cluster stays well under 20k input tokens.
const int _kContentCharCap = 1500;
class SignalGenerator {
final String apiKey;
SignalGenerator({required this.apiKey});
Future<List<EventSignal>> generateSignals(
List<EventCluster> clusters, {
required String ticker,
required String companyName,
}) async {
final filtered = clusters.where((c) => c.articles.isNotEmpty).toList();
final results = await Future.wait(
filtered.map(
(c) => generateSignal(c, ticker: ticker, companyName: companyName),
),
);
// sort by impact descending — impact is "how much should i care about this"
// probability is the credibility gate, not the headline number
results.sort((a, b) => b.impact.compareTo(a.impact));
return results;
}
Future<EventSignal> generateSignal(
EventCluster cluster, {
required String ticker,
required String companyName,
}) async {
final openRouter = OpenRouter(apiKey: apiKey);
final articles = cluster.articles;
final eventId = _eventIdFor(articles);
try {
final response = await openRouter.chat.completions.create(
model: "openai/gpt-4.1-mini",
messages: [
ChatMessage.system(_systemPrompt).toJson(),
ChatMessage.user(_buildPrompt(cluster, ticker: ticker, companyName: companyName)).toJson(),
],
temperature: 0.2,
) as ChatCompletion;
final rawContent = response.choices.first.message.content.trim();
final parsed = jsonDecode(_extractJson(rawContent)) as Map<String, dynamic>;
final signal = EventSignal.fromJson(parsed, articles, eventIdOverride: eventId);
// override createdAt with median pub date from the article cluster
return EventSignal(
eventId: signal.eventId,
eventSummary: signal.eventSummary,
direction: signal.direction,
nature: signal.nature,
probability: signal.probability,
impact: signal.impact,
rationale: signal.rationale,
articles: signal.articles,
createdAt: medianPubDate(articles),
);
} catch (e) {
print("Error generating signal: $e");
return EventSignal(
eventId: eventId,
eventSummary: _fallbackSummary(articles),
direction: "neutral",
probability: 0.0,
impact: 0.0,
rationale: "Signal generation failed.",
articles: articles,
createdAt: medianPubDate(articles),
);
} finally {
openRouter.dispose();
}
}
static const String _systemPrompt = """
You analyze clusters of business and finance news articles that plausibly concern a single asset (a stock, commodity, etc). Each cluster has been assembled via semantic similarity search — the articles are believed to be reporting on the same underlying event, not merely from the same time window. For each cluster you estimate how likely the event is real, how much the asset's price is likely to move in the short term, and the direction of that move.
Return valid JSON only, with exactly these keys:
event_summary, direction, nature, probability, impact, rationale
Field definitions:
- probability (number, 0.01.0): likelihood that the underlying event is real / actually happening as reported. Grounded primarily in coverage — number of articles, number of distinct publishers, reputability of those publishers, and how tightly the cluster hangs together semantically (lower avg distance = stronger corroboration). This is NOT the probability that the price moves, and it is NOT conditional on direction. Anchors:
* 0.1 — single article, unknown blog, speculative framing, no corroboration
* 0.5 — several articles from mid-tier outlets, or mixed/conflicting accounts across publishers
* 0.9 — wide coverage: many articles across multiple major wire services or papers of record (Reuters, Bloomberg, AP, WSJ, FT, NYT, etc.) converging on the same core facts
- impact (number, 0.01.0): expected magnitude of the asset's immediate price reaction over roughly the next few trading days, ASSUMING the event is real. Reasoned from what the event actually is, applied to this specific asset. Coverage volume is only a weak prior here — loud news is not the same as impactful news, and long-term significance is not the same as short-term reaction. Anchors (illustrative, oil-related asset):
* 0.1 — an OPEC minister makes a vague forward-looking comment about prices
* 0.5 — a refinery outage in a secondary producing region; a mid-sized earnings beat
* 0.9 — Strait of Hormuz closure; a surprise OPEC+ production cut of material size; a major sanctions announcement hitting the asset's supply or demand
- direction (string, enum): "positive" | "negative" | "neutral" — expected directional bias of the immediate price reaction for this specific asset. Kept separate from impact so a large negative and a large positive are both high-impact.
- nature (string, enum): "forecasting" if the cluster is predicting or anticipating a future event, "reactive" if it is reporting on something that already happened.
- event_summary (string): one neutral sentence describing the event itself. No hedging, no direction words.
- rationale (string): a short paragraph (24 sentences) covering two things, in this order:
1. What's actually happening in the cluster — more substantive than event_summary. Pull out the concrete facts: who did what, specific numbers, quoted figures, timelines, named actors. This is the reader learning what the news IS.
2. The causal chain from event → price reaction for THIS asset. Why does this move the asset in the chosen direction, and why by the chosen magnitude? Reference the mechanism (supply, demand, competition, margins, guidance, sentiment, regulatory exposure, etc).
DO NOT restate probability, the nature label, publisher count, source reputability, corroboration strength, or semantic tightness. All of that is shown in the UI alongside the rationale — repeating it wastes the only place the reader learns anything new. Focus on event substance and causal reasoning, not meta-commentary about the input data.
Important:
* probability is about the event being real, not about price movement.
* impact is about magnitude of short-term reaction, not long-term significance.
* direction is separate from impact.
* Return only the JSON object, no prose, no code fences.
""";
String _buildPrompt(
EventCluster cluster, {
required String ticker,
required String companyName,
}) {
final articles = cluster.articles;
final publishers = articles
.map((a) => (a.source ?? "").trim())
.where((s) => s.isNotEmpty)
.toSet()
.toList();
final buffer = StringBuffer();
buffer.writeln("Asset: $companyName ($ticker)");
buffer.writeln();
buffer.writeln("Coverage stats (computed, do not recount):");
buffer.writeln(" Articles: ${articles.length}");
buffer.writeln(" Distinct publishers: ${publishers.length}");
if (publishers.isNotEmpty) {
buffer.writeln(" Publishers: ${publishers.join(", ")}");
} else {
buffer.writeln(" Publishers: (none identified)");
}
final stats = cluster.distanceStats();
if (stats.min != null) {
buffer.writeln(
" Semantic tightness (distance from seed, 0=identical): "
"min ${stats.min!.toStringAsFixed(3)}, "
"avg ${stats.avg!.toStringAsFixed(3)}, "
"max ${stats.max!.toStringAsFixed(3)}",
);
} else {
buffer.writeln(" Semantic tightness: singleton cluster (no neighbours)");
}
buffer.writeln();
buffer.writeln("Articles:");
for (int i = 0; i < articles.length; i++) {
final article = articles[i];
buffer.writeln("${i + 1}. Title: ${article.title}");
if ((article.source ?? "").trim().isNotEmpty) {
buffer.writeln(" Publisher: ${article.source}");
}
final desc = article.description.trim();
if (desc.isNotEmpty) {
buffer.writeln(" Description: $desc");
}
final body = _clipContent(article.content);
if (body.isNotEmpty) {
buffer.writeln(" Content: $body");
}
buffer.writeln(" Link: ${article.link}");
}
buffer.writeln();
buffer.writeln("Return a single JSON object with keys: event_summary, direction, nature, probability, impact, rationale.");
return buffer.toString();
}
// trim + truncate article body to the char cap. returns empty string if
// theres nothing useful to include.
String _clipContent(String content) {
final trimmed = content.trim();
if (trimmed.isEmpty) return "";
if (trimmed.length <= _kContentCharCap) {
return trimmed;
}
return "${trimmed.substring(0, _kContentCharCap)}...";
}
String _extractJson(String content) {
final start = content.indexOf("{");
final end = content.lastIndexOf("}");
if (start == -1 || end == -1 || end < start) {
throw const FormatException("No JSON object found in model response.");
}
return content.substring(start, end + 1);
}
String _fallbackSummary(List<FeedItem> articles) {
if (articles.isEmpty) {
return "Unknown event";
}
return articles.first.title;
}
// deterministic id from the sorted link set — same cluster re-run produces
// the same id, which is handy for dedupe later.
String _eventIdFor(List<FeedItem> articles) {
final links = articles.map((a) => a.link).toList()..sort();
final joined = links.join("|");
final h = joined.hashCode & 0x7FFFFFFF;
return "evt_${h.toRadixString(36)}";
}
}
+251
View File
@@ -0,0 +1,251 @@
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
Color contentBgColor(BuildContext context) {
final theme = Theme.of(context);
final h = HSLColor.fromColor(theme.colorScheme.border).hue;
final dark = theme.brightness == Brightness.dark;
return dark
? HSLColor.fromAHSL(1, h, 0.35, 0.13).toColor()
: HSLColor.fromAHSL(1, h, 0.30, 0.88).toColor();
}
// The main shell. replaces Scaffold in all pages.
class AugorShell extends StatelessWidget {
const AugorShell({
super.key,
required this.child,
required this.titleTag,
this.headerLeading = const [],
this.headerTrailing = const [],
this.statusLeft,
this.statusRight,
});
final Widget child;
final String titleTag;
final List<Widget> headerLeading;
final List<Widget> headerTrailing;
final Widget? statusLeft;
final Widget? statusRight;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final monoStyle = GoogleFonts.ibmPlexMono(
fontSize: 11,
height: 1,
fontWeight: FontWeight.w600,
color: theme.colorScheme.mutedForeground,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── header ──────────────────────────────────────────────────────────
Container(
decoration: BoxDecoration(
color: theme.colorScheme.background,
border: Border(bottom: BorderSide(color: theme.colorScheme.border, width: 1)),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(left: 12, top: 4, bottom: 4),
child: Row(
children: headerLeading,
),
),
const Spacer(),
...headerTrailing,
// title tag box
Container(
color: theme.colorScheme.border,
constraints: const BoxConstraints(minWidth: 100),
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 10),
child: RichText(
text: TextSpan(text: titleTag.toUpperCase(), style: monoStyle),
),
),
const SizedBox(width: 64), // mac traffic lights gap
],
),
),
),
// ── content ─────────────────────────────────────────────────────────
Expanded(
child: Stack(
fit: StackFit.expand,
children: [
ColoredBox(
color: contentBgColor(context),
child: child,
),
const Positioned.fill(
child: IgnorePointer(
child: CustomPaint(painter: _InsetShadowPainter()),
),
),
],
),
),
// ── footer / status bar ─────────────────────────────────────────────
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: theme.colorScheme.background,
border: Border(top: BorderSide(color: theme.colorScheme.border, width: 1)),
),
child: Row(
children: [
Expanded(
child: statusLeft ?? const SizedBox.shrink(),
),
Expanded(
child: Center(
child: _NavItems(),
),
),
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: statusRight ?? const SizedBox.shrink(),
),
),
],
),
),
],
);
}
}
// shared footer nav — home and settings links
class _NavItems extends StatelessWidget {
@override
Widget build(BuildContext context) {
final location = GoRouterState.of(context).uri.toString();
final theme = Theme.of(context);
final monoStyle = GoogleFonts.ibmPlexMono(
fontSize: 11,
height: 1,
fontWeight: FontWeight.w600,
);
Widget navItem(String label, String path) {
final active = location == path || (path == '/' && location == '/');
return GestureDetector(
onTap: () => GoRouter.of(context).go(path),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: active ? theme.colorScheme.border : Colors.transparent,
borderRadius: BorderRadius.circular(3),
),
child: RichText(
text: TextSpan(
text: label,
style: monoStyle.copyWith(
color: active
? theme.colorScheme.foreground
: theme.colorScheme.mutedForeground,
),
),
),
),
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
navItem('Home', '/'),
const SizedBox(width: 2),
navItem('Settings', '/settings'),
],
);
}
}
// footer status text helper — use this for statusLeft / statusRight
class StatusText extends StatelessWidget {
const StatusText(this.text, {super.key});
final String text;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return RichText(
text: TextSpan(
text: text,
style: GoogleFonts.ibmPlexMono(
fontSize: 11,
height: 1,
fontWeight: FontWeight.w600,
color: theme.colorScheme.mutedForeground,
),
),
);
}
}
class _InsetShadowPainter extends CustomPainter {
const _InsetShadowPainter();
@override
void paint(Canvas canvas, Size size) {
const blur = 12.0;
final rect = Offset.zero & size;
canvas.save();
canvas.clipRect(rect);
final innerRect = rect.deflate(24);
final ringClip = Path()
..addRect(rect)
..addRect(innerRect)
..fillType = PathFillType.evenOdd;
canvas.clipPath(ringClip);
final path = Path()
..addRect(rect.inflate(blur * 2))
..addRect(rect)
..fillType = PathFillType.evenOdd;
final paint = Paint()
..color = const Color(0x55000000)
..maskFilter = const MaskFilter.blur(BlurStyle.normal, blur);
canvas.drawPath(path, paint);
canvas.restore();
}
@override
bool shouldRepaint(covariant CustomPainter old) => false;
}
+318
View File
@@ -0,0 +1,318 @@
import 'package:capstone_project/models/event_signal.dart';
import 'package:capstone_project/utils/signal_buckets.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
class EventSignalCard extends StatelessWidget {
final EventSignal signal;
const EventSignalCard({super.key, required this.signal});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final signalColor = switch (signal.direction) {
'positive' => const Color(0xFF16a34a),
'negative' => const Color(0xFFdc2626),
_ => theme.colorScheme.mutedForeground,
};
final signalBg = switch (signal.direction) {
'positive' => const Color(0xFF16a34a).withValues(alpha: 0.12),
'negative' => const Color(0xFFdc2626).withValues(alpha: 0.12),
_ => theme.colorScheme.border.withValues(alpha: 0.4),
};
return Container(
decoration: BoxDecoration(
color: theme.colorScheme.background.withValues(alpha: 0.8),
border: Border.all(color: theme.colorScheme.border, width: 1),
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.12),
blurRadius: 4,
spreadRadius: 2,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// header row
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: theme.colorScheme.border, width: 1)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(signal.eventSummary).semiBold,
const Gap(4),
RichText(
text: TextSpan(
text: _formatDate(signal.createdAt),
style: GoogleFonts.ibmPlexMono(
fontSize: 10,
height: 1,
fontWeight: FontWeight.w500,
color: theme.colorScheme.mutedForeground,
),
),
),
],
),
),
const Gap(12),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: theme.colorScheme.border.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: theme.colorScheme.border, width: 1),
),
child: RichText(
text: TextSpan(
text: signal.nature,
style: GoogleFonts.ibmPlexMono(
fontSize: 10,
height: 1,
fontWeight: FontWeight.w600,
color: theme.colorScheme.mutedForeground,
),
),
),
),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: signalBg,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: signalColor.withValues(alpha: 0.3), width: 1),
),
child: RichText(
text: TextSpan(
text: signal.direction,
style: GoogleFonts.ibmPlexMono(
fontSize: 10,
height: 1,
fontWeight: FontWeight.w600,
color: signalColor,
),
),
),
),
],
),
],
),
),
// probability + impact as bucketed labels. the raw percentages
// pretended to more precision than the llm actually gives us.
Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 0),
child: Row(
children: [
_BucketRow(
label: 'CREDIBILITY',
bucket: bucketLabel(signal.probability),
color: signalColor,
),
const SizedBox(width: 20),
_BucketRow(
label: 'IMPACT',
bucket: bucketLabel(signal.impact),
color: signalColor,
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 0),
child: Text(signal.rationale).small,
),
// articles
if (signal.articles.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 0),
child: Divider(color: Theme.of(context).colorScheme.border, height: 1),
),
Padding(
padding: const EdgeInsets.fromLTRB(14, 8, 14, 0),
child: RichText(
text: TextSpan(
text: 'SOURCES ${signal.articles.length}',
style: GoogleFonts.ibmPlexMono(
fontSize: 10,
height: 1,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.mutedForeground,
),
),
),
),
for (final article in signal.articles)
_ArticleLink(article: article),
],
const SizedBox(height: 14),
],
),
);
}
}
class _ArticleLink extends StatefulWidget {
final dynamic article; // FeedItem
const _ArticleLink({required this.article});
@override
State<_ArticleLink> createState() => _ArticleLinkState();
}
class _ArticleLinkState extends State<_ArticleLink> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hasLink = widget.article.link.isNotEmpty;
return MouseRegion(
cursor: hasLink ? SystemMouseCursors.click : MouseCursor.defer,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: hasLink
? () => launchUrl(Uri.parse(widget.article.link), mode: LaunchMode.externalApplication)
: null,
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 6, 14, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(
LucideIcons.externalLink,
size: 10,
color: _hovered
? theme.colorScheme.foreground
: theme.colorScheme.mutedForeground,
),
),
const SizedBox(width: 6),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.article.title,
style: TextStyle(
fontSize: 11,
color: _hovered
? theme.colorScheme.foreground
: theme.colorScheme.mutedForeground,
decoration: _hovered ? TextDecoration.underline : TextDecoration.none,
decorationColor: theme.colorScheme.mutedForeground,
),
),
if (widget.article.source != null) ...[
const SizedBox(height: 2),
RichText(
text: TextSpan(
text: widget.article.source,
style: GoogleFonts.ibmPlexMono(
fontSize: 9,
height: 1,
fontWeight: FontWeight.w500,
color: theme.colorScheme.mutedForeground.withValues(alpha: 0.6),
),
),
),
],
],
),
),
],
),
),
),
);
}
}
// small two-piece label: "LABEL Value". kept compact so we can stack
// credibility and impact on one line without the card feeling cramped.
class _BucketRow extends StatelessWidget {
final String label;
final String bucket;
final Color color;
const _BucketRow({
required this.label,
required this.bucket,
required this.color,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
RichText(
text: TextSpan(
text: label,
style: GoogleFonts.ibmPlexMono(
fontSize: 10,
height: 1,
fontWeight: FontWeight.w600,
color: theme.colorScheme.mutedForeground,
),
),
),
const SizedBox(width: 8),
RichText(
text: TextSpan(
text: bucket.toUpperCase(),
style: GoogleFonts.ibmPlexMono(
fontSize: 10,
height: 1,
fontWeight: FontWeight.w700,
color: color,
),
),
),
],
);
}
}
String _formatDate(DateTime dt) {
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
final h = dt.hour.toString().padLeft(2, '0');
final m = dt.minute.toString().padLeft(2, '0');
return '${months[dt.month - 1]} ${dt.day}, ${dt.year} $h:$m';
}
+1 -53
View File
@@ -1,53 +1 @@
import 'package:go_router/go_router.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
class ProjNavBar extends StatelessWidget {
static final Map<String, int> _pageIndex = {
"home": 0,
"settings": 1
};
late final int selectedIndex;
ProjNavBar({super.key, String currentPage = "home"}) {;
selectedIndex = _pageIndex[currentPage] ?? 0;
}
@override
Widget build(BuildContext context) {
// TODO: implement build
return NavigationBar(
index: selectedIndex,
onSelected: (index) {
if (index == 0) {
GoRouter.of(context).go("/");
} else if (index == 1) {
GoRouter.of(context).go("/settings");
}
},
children: [
NavigationItem(
label: Text(
"Home"
),
child: Icon(
LucideIcons.house
),
),
NavigationItem(
label: Text(
"Settings"
),
child: Icon(
LucideIcons.settings
),
)
],
);
}
}
// deprecated — navigation is now handled by AugorShell footer
+239
View File
@@ -0,0 +1,239 @@
import "dart:math" as math;
import "package:shadcn_flutter/shadcn_flutter.dart";
class PanelField {
const PanelField({
required this.section,
required this.label,
required this.child,
this.enabled = true,
});
final String section;
final Widget label;
final Widget child;
final bool enabled;
}
// auto groups fields by section string
class PanelList extends StatelessWidget {
const PanelList({super.key, required this.fields});
final List<PanelField> fields;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final Map<String, List<PanelField>> grouped = {};
for (final f in fields) {
grouped.putIfAbsent(f.section, () => []).add(f);
}
final entries = grouped.entries.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (int i = 0; i < entries.length; i++) ...[
PanelSection(
title: entries[i].key,
children: entries[i].value
.map((f) => PanelRow(label: f.label, child: f.child, enabled: f.enabled))
.toList(),
),
if (i < entries.length - 1)
Divider(color: theme.colorScheme.border, height: 1),
],
Divider(color: theme.colorScheme.border, height: 1),
const SizedBox(height: 10),
],
);
}
}
class PanelSection extends StatefulWidget {
const PanelSection({
super.key,
required this.title,
required this.children,
});
final String title;
final List<Widget> children;
@override
State<PanelSection> createState() => _PanelSectionState();
}
class _PanelSectionState extends State<PanelSection> {
bool _expanded = true;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
behavior: HitTestBehavior.opaque,
child: ColoredBox(
color: theme.colorScheme.secondary,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [
AnimatedRotation(
turns: _expanded ? 0.0 : -0.25,
duration: const Duration(milliseconds: 120),
child: Icon(LucideIcons.chevronDown, size: 12, color: theme.colorScheme.mutedForeground),
),
const SizedBox(width: 4),
Text(
widget.title,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: theme.colorScheme.foreground,
letterSpacing: 0.4,
),
),
],
),
),
),
),
Divider(color: theme.colorScheme.border, height: 1),
if (_expanded)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (int i = 0; i < widget.children.length; i++) ...[
widget.children[i],
if (i < widget.children.length - 1)
Divider(color: theme.colorScheme.border, height: 1),
],
],
),
],
);
}
}
class PanelRow extends StatelessWidget {
const PanelRow({super.key, required this.label, required this.child, this.enabled = true});
final Widget label;
final Widget child;
final bool enabled;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ConstrainedBox(
constraints: const BoxConstraints(minHeight: 38),
child: IntrinsicHeight(
child: Padding(
padding: const EdgeInsets.only(left: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: 120,
child: Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: DefaultTextStyle.merge(
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.mutedForeground,
decoration: !enabled ? TextDecoration.lineThrough : null,
decorationThickness: !enabled ? 3 : null,
),
child: label,
),
),
),
),
VerticalDivider(color: theme.colorScheme.border, width: 1),
Expanded(
child: Stack(
children: [
Positioned.fill(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: child,
),
),
if (!enabled)
Positioned.fill(
child: GestureDetector(
onTap: () {},
behavior: HitTestBehavior.opaque,
child: CustomPaint(
painter: _DisabledStripePainter(theme.colorScheme.border),
),
),
),
],
),
),
],
),
),
),
);
}
}
class _DisabledStripePainter extends CustomPainter {
const _DisabledStripePainter(this.color);
final Color color;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color.withValues(alpha: 1)
..strokeWidth = 1.0
..style = PaintingStyle.stroke;
const spacing = 4.0;
final diag = math.sqrt(size.width * size.width + size.height * size.height);
final count = (diag / spacing).ceil() + 2;
canvas.save();
canvas.clipRect(Offset.zero & size);
canvas.drawRect(Rect.fromLTWH(1, 1, size.width - 3, size.height - 2), paint);
canvas.translate(0, size.height);
canvas.rotate(-math.pi / 4);
for (int i = -count; i <= count; i++) {
final x = i * spacing;
canvas.drawLine(Offset(x, -diag), Offset(x, diag), paint);
}
canvas.restore();
}
@override
bool shouldRepaint(_DisabledStripePainter old) => old.color != color;
}
+214
View File
@@ -0,0 +1,214 @@
import "dart:math";
import "package:capstone_project/models/event_signal.dart";
import "package:capstone_project/utils/signal_buckets.dart";
import "package:fl_chart/fl_chart.dart";
import "package:google_fonts/google_fonts.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
const Color _growthColor = Color(0xFF16a34a);
const Color _declineColor = Color(0xFFdc2626);
const Color _neutralColor = Color(0xFF888888);
// one day in milliseconds — used to convert between DateTime and the chart's x axis
const double _msPerDay = 1000 * 60 * 60 * 24;
// Scatter of signals: X is the signal date, Y is the impact, color is direction,
// opacity scales with probability (low-credibility signals fade into the back).
// No line connecting them — signals are discrete events, not a time series.
class SignalProbabilityChart extends StatelessWidget {
final List<EventSignal> signals;
final double height;
final bool compact;
const SignalProbabilityChart({
super.key,
required this.signals,
this.height = 200,
this.compact = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (signals.isEmpty) {
return Container(
width: double.infinity,
height: height,
decoration: BoxDecoration(
color: theme.colorScheme.secondary,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: theme.colorScheme.border, width: 1),
),
alignment: Alignment.center,
child: Text("No signal history yet").muted.small,
);
}
// sort so index ordering lines up with the spots list (used for tooltip lookup)
final sorted = [...signals]..sort((a, b) => a.createdAt.compareTo(b.createdAt));
final xs = sorted.map((s) => _toDays(s.createdAt)).toList();
final rawMinX = xs.reduce(min);
final rawMaxX = xs.reduce(max);
// keep a minimum visual span so a single-date cluster doesn't collapse the axis
final span = (rawMaxX - rawMinX) < 1.0 ? 1.0 : (rawMaxX - rawMinX);
final xPad = span * 0.06;
final minX = rawMinX - xPad;
final maxX = rawMaxX + xPad;
final spots = <ScatterSpot>[];
for (final s in sorted) {
final base = _directionColor(s.direction);
// probability drives the alpha: 0.0 → 0.30, 1.0 → 0.95
final alpha = 0.30 + (s.probability.clamp(0.0, 1.0) * 0.65);
spots.add(
ScatterSpot(
_toDays(s.createdAt),
s.impact,
dotPainter: FlDotCirclePainter(
radius: 5,
color: base.withValues(alpha: alpha),
strokeWidth: 1,
strokeColor: theme.colorScheme.background,
),
),
);
}
final labelStyle = GoogleFonts.ibmPlexMono(
fontSize: 10,
height: 1,
fontWeight: FontWeight.w500,
color: theme.colorScheme.mutedForeground,
);
return SizedBox(
width: double.infinity,
height: height,
child: ScatterChart(
ScatterChartData(
minX: minX,
maxX: maxX,
minY: 0,
maxY: 1,
scatterSpots: spots,
gridData: FlGridData(
show: !compact,
drawVerticalLine: false,
horizontalInterval: 0.25,
getDrawingHorizontalLine: (_) => FlLine(
color: theme.colorScheme.border,
strokeWidth: 1,
),
),
borderData: FlBorderData(
show: !compact,
border: Border.all(color: theme.colorScheme.border, width: 1),
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: !compact,
reservedSize: 22,
interval: _xTickInterval(minX, maxX),
getTitlesWidget: (value, meta) {
final d = DateTime.fromMillisecondsSinceEpoch(
(value * _msPerDay).round(),
);
return Padding(
padding: const EdgeInsets.only(top: 4),
child: RichText(text: TextSpan(text: _shortDate(d), style: labelStyle)),
);
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: !compact,
reservedSize: 42,
interval: 0.25,
getTitlesWidget: (value, meta) => RichText(
text: TextSpan(
text: "${(value * 100).round()}%",
style: labelStyle,
),
),
),
),
),
scatterTouchData: compact
? ScatterTouchData(enabled: false)
: ScatterTouchData(
enabled: true,
touchTooltipData: ScatterTouchTooltipData(
getTooltipColor: (_) => theme.colorScheme.background,
tooltipBorder: BorderSide(color: theme.colorScheme.border, width: 1),
tooltipPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
maxContentWidth: 220,
getTooltipItems: (ScatterSpot touched) {
// match back to the source signal by x+y — ScatterSpot equality
// isn't guaranteed to line up with our original instances
final idx = sorted.indexWhere((s) =>
_toDays(s.createdAt) == touched.x && s.impact == touched.y);
if (idx < 0) return null;
final s = sorted[idx];
return ScatterTooltipItem(
"${_shortDate(s.createdAt)}\n"
"impact ${(s.impact * 100).toStringAsFixed(0)}% · ${s.direction}\n"
"probability ${(s.probability * 100).toStringAsFixed(0)}%",
textStyle: GoogleFonts.ibmPlexMono(
fontSize: 11,
fontWeight: FontWeight.w600,
color: theme.colorScheme.foreground,
),
textAlign: TextAlign.left,
);
},
),
),
),
),
);
}
}
double _toDays(DateTime d) => d.millisecondsSinceEpoch / _msPerDay;
Color _directionColor(String direction) => switch (direction) {
"positive" => _growthColor,
"negative" => _declineColor,
_ => _neutralColor,
};
// pick a sensible x-axis tick interval (in days) based on the visible range
double _xTickInterval(double minX, double maxX) {
final days = (maxX - minX).abs();
if (days <= 7) return 1;
if (days <= 30) return 7;
if (days <= 90) return 14;
if (days <= 365) return 30;
return (days / 6).ceilToDouble();
}
String _shortDate(DateTime d) {
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
return "${months[d.month - 1]} ${d.day}";
}
+258
View File
@@ -0,0 +1,258 @@
import 'dart:math';
import 'package:capstone_project/models/event_signal.dart';
import 'package:capstone_project/services/stock_price_service.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
const Color _growthColor = Color(0xFF16a34a);
const Color _declineColor = Color(0xFFdc2626);
class StockPriceChart extends StatelessWidget {
final List<StockPricePoint> prices;
final List<EventSignal> signals;
final double height;
final bool compact;
const StockPriceChart({
super.key,
required this.prices,
this.signals = const [],
this.height = 220,
this.compact = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (prices.isEmpty) {
return Container(
width: double.infinity,
height: height,
decoration: BoxDecoration(
color: theme.colorScheme.secondary,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: theme.colorScheme.border, width: 1),
),
alignment: Alignment.center,
child: Text('No price history available').muted.small,
);
}
final sortedPrices = [...prices]..sort((a, b) => a.date.compareTo(b.date));
final spots = List.generate(
sortedPrices.length,
(i) => FlSpot(i.toDouble(), sortedPrices[i].close),
);
final minPrice = sortedPrices.map((p) => p.close).reduce(min);
final maxPrice = sortedPrices.map((p) => p.close).reduce(max);
final pad = max((maxPrice - minPrice) * 0.15, maxPrice * 0.02);
final lineColor = sortedPrices.last.close >= sortedPrices.first.close ? _growthColor : _declineColor;
final labelStyle = GoogleFonts.ibmPlexMono(
fontSize: 10,
height: 1,
fontWeight: FontWeight.w500,
color: theme.colorScheme.mutedForeground,
);
return SizedBox(
width: double.infinity,
height: height,
child: LineChart(
LineChartData(
minY: minPrice - pad,
maxY: maxPrice + pad,
minX: 0,
maxX: spots.length > 1 ? (spots.length - 1).toDouble() : 1,
gridData: FlGridData(
show: !compact,
drawVerticalLine: false,
horizontalInterval: _priceInterval(minPrice, maxPrice),
getDrawingHorizontalLine: (_) => FlLine(
color: theme.colorScheme.border,
strokeWidth: 1,
),
),
borderData: FlBorderData(
show: !compact,
border: Border.all(color: theme.colorScheme.border, width: 1),
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: !compact,
reservedSize: 22,
interval: _xInterval(sortedPrices.length),
getTitlesWidget: (value, meta) {
final index = value.round();
if (index < 0 || index >= sortedPrices.length) return const SizedBox.shrink();
if ((value - index).abs() > 0.01) return const SizedBox.shrink();
final d = sortedPrices[index].date;
return Padding(
padding: const EdgeInsets.only(top: 4),
child: RichText(text: TextSpan(text: _shortDate(d), style: labelStyle)),
);
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: !compact,
reservedSize: 52,
interval: _priceInterval(minPrice, maxPrice),
getTitlesWidget: (value, meta) => RichText(
text: TextSpan(text: '\$${value.toStringAsFixed(0)}', style: labelStyle),
),
),
),
),
lineTouchData: compact
? LineTouchData(enabled: false)
: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipColor: (_) => theme.colorScheme.background,
tooltipBorder: BorderSide(color: theme.colorScheme.border, width: 1),
tooltipPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
getTooltipItems: (touchedSpots) {
return touchedSpots.map((spot) {
final index = spot.x.round();
if (index < 0 || index >= sortedPrices.length) return null;
final d = sortedPrices[index].date;
return LineTooltipItem(
'${_shortDate(d)}\n\$${spot.y.toStringAsFixed(2)}',
GoogleFonts.ibmPlexMono(
fontSize: 11,
fontWeight: FontWeight.w600,
color: theme.colorScheme.foreground,
),
);
}).toList();
},
),
),
extraLinesData: ExtraLinesData(
extraLinesOnTop: true,
horizontalLines: [],
verticalLines: compact ? [] : _buildSignalLines(sortedPrices, signals, theme),
),
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: true,
color: lineColor,
barWidth: compact ? 2.5 : 2,
belowBarData: compact
? BarAreaData(show: false)
: BarAreaData(show: true, color: lineColor.withValues(alpha: 0.10)),
dotData: FlDotData(
show: !compact,
getDotPainter: (spot, percent, barData, index) {
final matchingSignal = _nearestSignal(sortedPrices[index].date, signals);
if (matchingSignal == null) {
return FlDotCirclePainter(radius: 0, color: Colors.transparent);
}
return FlDotCirclePainter(
radius: 4,
color: _signalColor(matchingSignal.direction),
strokeWidth: 1.5,
strokeColor: theme.colorScheme.background,
);
},
),
),
],
),
),
);
}
List<VerticalLine> _buildSignalLines(
List<StockPricePoint> prices,
List<EventSignal> signals,
dynamic theme,
) {
return signals
.map((signal) {
final idx = _nearestPriceIndex(prices, signal.createdAt);
if (idx == null) return null;
return VerticalLine(
x: idx.toDouble(),
color: _signalColor(signal.direction).withValues(alpha: 0.4),
strokeWidth: 1,
dashArray: [4, 4],
);
})
.whereType<VerticalLine>()
.toList();
}
EventSignal? _nearestSignal(DateTime priceDate, List<EventSignal> signals) {
EventSignal? nearest;
Duration? smallest;
for (final s in signals) {
final diff = s.createdAt.difference(priceDate).abs();
if (smallest == null || diff < smallest) {
smallest = diff;
nearest = s;
}
}
return (smallest != null && smallest <= const Duration(days: 2)) ? nearest : null;
}
int? _nearestPriceIndex(List<StockPricePoint> prices, DateTime target) {
if (prices.isEmpty) return null;
int idx = 0;
Duration nearest = prices.first.date.difference(target).abs();
for (int i = 1; i < prices.length; i++) {
final diff = prices[i].date.difference(target).abs();
if (diff < nearest) {
nearest = diff;
idx = i;
}
}
return nearest <= const Duration(days: 7) ? idx : null;
}
double _priceInterval(double min, double max) {
final range = max - min;
if (range <= 1) return 0.25;
if (range <= 5) return 1;
if (range <= 20) return 5;
if (range <= 100) return 20;
return range / 4;
}
double _xInterval(int length) {
if (length <= 7) return 1;
if (length <= 14) return 3;
return (length / 4).ceilToDouble();
}
Color _signalColor(String direction) => switch (direction) {
'positive' => _growthColor,
'negative' => _declineColor,
_ => const Color(0xFF888888),
};
}
String _shortDate(DateTime d) {
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
return '${months[d.month - 1]} ${d.day}';
}