Refactor project structure and enhance stock watchlist functionality
This commit is contained in:
@@ -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
@@ -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,
|
||||
]
|
||||
);
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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.58–0.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(),
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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.0–1.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.0–1.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 (2–4 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)}";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
@@ -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}';
|
||||
}
|
||||
Reference in New Issue
Block a user