Refactor project structure and enhance stock watchlist functionality

This commit is contained in:
ImBenji 2026-04-21 12:09:16 +01:00
parent 491bf2bbea
commit ed617b7498
41 changed files with 6566 additions and 1289 deletions

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,9 @@
{
"id": "880a7a44-1b1f-424d-bd08-eb39698566fb",
"name": "New Chat",
"created": "2026-04-15T22:57:09.918161Z",
"updated": "2026-04-15T22:57:09.918161Z",
"messages": [],
"model": "openai/gpt-5.4",
"workingDirectory": "/Users/imbenji/StudioProjects/capstone_project"
}

View file

@ -4,7 +4,7 @@
## What is this? ## What is this?
Augor is a Flutter desktop application that aggregates financial news from multiple RSS sources and uses OpenAI embeddings to cluster related articles about the same business events. Instead of manually tracking dozens of news sources, Augor automatically groups stories so you can see what's actually happening in markets. Augor is a Flutter desktop application that aggregates financial news from multiple RSS sources and uses OpenRouter-hosted embeddings and LLM analysis to cluster related articles about the same business events and generate probabilistic market signals.
## Current Features ## Current Features
@ -15,13 +15,13 @@ Augor is a Flutter desktop application that aggregates financial news from multi
- Enable/disable feeds individually - Enable/disable feeds individually
**AI-Powered Processing** **AI-Powered Processing**
- Generates embeddings using OpenAI's text-embedding-3-small model - Generates embeddings using OpenRouter-hosted `openai/text-embedding-3-small`
- Filters articles for business relevance using keyword similarity - Filters articles for business relevance using keyword similarity
- Groups related articles about the same event using cosine similarity - Groups related articles about the same event using cosine similarity
- Exports results as JSON for further analisis - Exports results as JSON for further analisis
**Settings Management** **Settings Management**
- Configure OpenAI API key - Configure OpenRouter API key
- Manage RSS feed sources - Manage RSS feed sources
- Set custom storage location for output files - Set custom storage location for output files
@ -29,7 +29,7 @@ Augor is a Flutter desktop application that aggregates financial news from multi
- **Flutter** - Cross-platform desktop app (macOS, Windows, Linux) - **Flutter** - Cross-platform desktop app (macOS, Windows, Linux)
- **shadcn_flutter** - UI component library - **shadcn_flutter** - UI component library
- **OpenAI API** - Text embeddings for semantic similarity - **OpenRouter API** - Embeddings and LLM inference
- **Provider** - State managment - **Provider** - State managment
- **go_router** - Navigation - **go_router** - Navigation
@ -40,7 +40,7 @@ Augor is a Flutter desktop application that aggregates financial news from multi
```bash ```bash
flutter pub get flutter pub get
``` ```
3. Create a `.env` file with your OpenAI API key (or configure it in the app settings) 3. Add your OpenRouter API key in the app settings
4. Run the app: 4. Run the app:
```bash ```bash
flutter run flutter run

123
architecture.md Normal file
View file

@ -0,0 +1,123 @@
# Architecture
## Overview
Augor is a Flutter desktop application that aggregates business and finance news feeds, clusters related articles into events, uses an LLM to assess likely market impact, and outputs a probabilistic growth signal for each event.
## MVP Goal
Deliver a working desktop prototype that:
- fetches articles from configured RSS/Atom feeds
- groups related reporting into event clusters
- asks an LLM to interpret each event cluster
- outputs a signal of `growth`, `decline`, or `neutral`
- includes a `probability` and `confidence` score
- displays results in the UI and exports them to JSON
## What Is In Scope For MVP
- RSS/Atom feed ingestion
- Embedding generation for articles
- Relevance filtering for business/finance news
- Event clustering using cosine similarity
- LLM event interpretation per cluster
- Signal generation: `growth`, `decline`, `neutral`
- Probability/confidence scoring
- Result display in Flutter UI
- JSON export of generated signals
## What Is Out Of Scope For MVP
- Backtesting
- Automated trading execution
- Backend/cloud processing
- Historical price-data integration
- Company/ticker extraction
- Multi-model comparison
- Advanced bias reduction or source weighting
## Runtime Components
- `lib/main.dart`
- App entrypoint.
- Loads persisted settings before rendering UI.
- Registers `SettingsProvider` in `MultiProvider`.
- Configures navigation with `go_router`.
- `lib/pages/home.dart`
- Main user flow trigger.
- Runs feed fetch, embedding generation, relevance filtering, grouping, LLM signal generation, export, and result rendering.
- `lib/pages/settings.dart`
- Settings UI for API key, feed list management, and output directory selection.
- `lib/providers/settings.dart`
- `ChangeNotifier` state container.
- Persists OpenRouter key, feed list, and storage path with `SharedPreferences`.
- `lib/utils/agrigator.dart`
- Feed parsing, feed retrieval, embeddings, relevance filtering, and event grouping.
- `lib/utils/openrouter.dart`
- OpenRouter API wrapper for embeddings and chat completions.
- `lib/utils/signal_generator.dart`
- Builds event-cluster prompts.
- Calls chat completions.
- Parses structured JSON into app signal models.
- `lib/models/event_signal.dart`
- Data model for a generated event signal.
- `lib/widgets/event_signal_card.dart`
- Displays one generated signal in the UI.
## MVP Data Flow
1. App startup loads persisted settings in `SettingsProvider.load()`.
2. User starts analysis from the Home page.
3. App builds the list of enabled feed URLs.
4. Feeds are fetched and parsed into `FeedItem`s.
5. Raw aggregated articles are exported to `aggregated_feed.json`.
6. Embeddings are generated for each article using `text-embedding-3-small`.
7. Enriched articles are exported to `enriched_aggregated_feed.json`.
8. Articles are filtered for business/finance relevance.
9. Relevant articles are exported to `relevant_aggregated_feed.json`.
10. Relevant articles are grouped into event clusters by cosine similarity.
11. Grouped readable output is exported.
12. Each event cluster is sent to an LLM prompt for interpretation.
13. The LLM returns structured JSON containing:
- `event_summary`
- `signal`
- `probability`
- `confidence`
- `rationale`
14. Parsed signals are displayed in the UI.
15. Signals are exported to `signals.json`.
## LLM Output Contract
Each analyzed event cluster must produce JSON in this shape:
```json
{
"event_summary": "short description of the event",
"signal": "growth",
"probability": 0.78,
"confidence": 0.71,
"rationale": "why this event suggests growth"
}
```
Allowed signal values:
- `growth`
- `decline`
- `neutral`
## MVP Build Order
1. Add `EventSignal` model.
2. Add `signal_generator.dart` for prompt building and LLM parsing.
3. Wire signal generation into `lib/pages/home.dart` after clustering.
4. Add basic signal result rendering in the UI.
5. Export generated signals to `signals.json`.
6. Validate the end-to-end flow in the desktop app.
## Current Constraints
- Processing is still UI-triggered and client-side only.
- No cancellation or progress reporting beyond basic UI state.
- No retry/backoff for network or model calls.
- No historical validation or backtesting in MVP.
- Quality of outputs depends on feed quality and LLM consistency.
## Definition Of Done For MVP
- User clicks the main action on Home.
- App fetches articles from enabled feeds.
- App groups related stories into events.
- App generates one interpreted signal per event cluster.
- UI shows event summary, signal, probability, confidence, and rationale.
- Signals are written to `signals.json` in the configured output directory.

123
lib/data/stock_list.dart Normal file
View file

@ -0,0 +1,123 @@
const List<Map<String, String>> stockList = [
{'ticker': 'AAPL', 'name': 'Apple Inc.'},
{'ticker': 'MSFT', 'name': 'Microsoft Corporation'},
{'ticker': 'NVDA', 'name': 'NVIDIA Corporation'},
{'ticker': 'AMZN', 'name': 'Amazon.com, Inc.'},
{'ticker': 'GOOGL', 'name': 'Alphabet Inc. Class A'},
{'ticker': 'GOOG', 'name': 'Alphabet Inc. Class C'},
{'ticker': 'META', 'name': 'Meta Platforms, Inc.'},
{'ticker': 'TSLA', 'name': 'Tesla, Inc.'},
{'ticker': 'BRK.B', 'name': 'Berkshire Hathaway Inc. Class B'},
{'ticker': 'JPM', 'name': 'JPMorgan Chase & Co.'},
{'ticker': 'AVGO', 'name': 'Broadcom Inc.'},
{'ticker': 'LLY', 'name': 'Eli Lilly and Company'},
{'ticker': 'V', 'name': 'Visa Inc.'},
{'ticker': 'MA', 'name': 'Mastercard Incorporated'},
{'ticker': 'XOM', 'name': 'Exxon Mobil Corporation'},
{'ticker': 'UNH', 'name': 'UnitedHealth Group Incorporated'},
{'ticker': 'COST', 'name': 'Costco Wholesale Corporation'},
{'ticker': 'WMT', 'name': 'Walmart Inc.'},
{'ticker': 'JNJ', 'name': 'Johnson & Johnson'},
{'ticker': 'PG', 'name': 'The Procter & Gamble Company'},
{'ticker': 'HD', 'name': 'The Home Depot, Inc.'},
{'ticker': 'ABBV', 'name': 'AbbVie Inc.'},
{'ticker': 'BAC', 'name': 'Bank of America Corporation'},
{'ticker': 'KO', 'name': 'The Coca-Cola Company'},
{'ticker': 'PEP', 'name': 'PepsiCo, Inc.'},
{'ticker': 'MRK', 'name': 'Merck & Co., Inc.'},
{'ticker': 'CVX', 'name': 'Chevron Corporation'},
{'ticker': 'ORCL', 'name': 'Oracle Corporation'},
{'ticker': 'ADBE', 'name': 'Adobe Inc.'},
{'ticker': 'CRM', 'name': 'Salesforce, Inc.'},
{'ticker': 'AMD', 'name': 'Advanced Micro Devices, Inc.'},
{'ticker': 'NFLX', 'name': 'Netflix, Inc.'},
{'ticker': 'CSCO', 'name': 'Cisco Systems, Inc.'},
{'ticker': 'TMO', 'name': 'Thermo Fisher Scientific Inc.'},
{'ticker': 'ACN', 'name': 'Accenture plc'},
{'ticker': 'MCD', 'name': 'McDonald\'s Corporation'},
{'ticker': 'ABT', 'name': 'Abbott Laboratories'},
{'ticker': 'LIN', 'name': 'Linde plc'},
{'ticker': 'DHR', 'name': 'Danaher Corporation'},
{'ticker': 'QCOM', 'name': 'QUALCOMM Incorporated'},
{'ticker': 'INTU', 'name': 'Intuit Inc.'},
{'ticker': 'TXN', 'name': 'Texas Instruments Incorporated'},
{'ticker': 'IBM', 'name': 'International Business Machines Corporation'},
{'ticker': 'AMGN', 'name': 'Amgen Inc.'},
{'ticker': 'PM', 'name': 'Philip Morris International Inc.'},
{'ticker': 'GE', 'name': 'General Electric Company'},
{'ticker': 'CAT', 'name': 'Caterpillar Inc.'},
{'ticker': 'NOW', 'name': 'ServiceNow, Inc.'},
{'ticker': 'SPGI', 'name': 'S&P Global Inc.'},
{'ticker': 'GS', 'name': 'The Goldman Sachs Group, Inc.'},
{'ticker': 'BLK', 'name': 'BlackRock, Inc.'},
{'ticker': 'AXP', 'name': 'American Express Company'},
{'ticker': 'ISRG', 'name': 'Intuitive Surgical, Inc.'},
{'ticker': 'PLTR', 'name': 'Palantir Technologies Inc.'},
{'ticker': 'UBER', 'name': 'Uber Technologies, Inc.'},
{'ticker': 'SHOP', 'name': 'Shopify Inc.'},
{'ticker': 'SQ', 'name': 'Block, Inc.'},
{'ticker': 'PYPL', 'name': 'PayPal Holdings, Inc.'},
{'ticker': 'DIS', 'name': 'The Walt Disney Company'},
{'ticker': 'NKE', 'name': 'NIKE, Inc.'},
{'ticker': 'SBUX', 'name': 'Starbucks Corporation'},
{'ticker': 'PFE', 'name': 'Pfizer Inc.'},
{'ticker': 'BA', 'name': 'The Boeing Company'},
{'ticker': 'F', 'name': 'Ford Motor Company'},
{'ticker': 'GM', 'name': 'General Motors Company'},
{'ticker': 'T', 'name': 'AT&T Inc.'},
{'ticker': 'VZ', 'name': 'Verizon Communications Inc.'},
{'ticker': 'INTC', 'name': 'Intel Corporation'},
{'ticker': 'MU', 'name': 'Micron Technology, Inc.'},
{'ticker': 'LRCX', 'name': 'Lam Research Corporation'},
{'ticker': 'KLAC', 'name': 'KLA Corporation'},
{'ticker': 'ADI', 'name': 'Analog Devices, Inc.'},
{'ticker': 'PANW', 'name': 'Palo Alto Networks, Inc.'},
{'ticker': 'CRWD', 'name': 'CrowdStrike Holdings, Inc.'},
{'ticker': 'SNOW', 'name': 'Snowflake Inc.'},
{'ticker': 'MDB', 'name': 'MongoDB, Inc.'},
{'ticker': 'DDOG', 'name': 'Datadog, Inc.'},
{'ticker': 'ZS', 'name': 'Zscaler, Inc.'},
{'ticker': 'RIVN', 'name': 'Rivian Automotive, Inc.'},
{'ticker': 'LCID', 'name': 'Lucid Group, Inc.'},
{'ticker': 'COIN', 'name': 'Coinbase Global, Inc.'},
{'ticker': 'HOOD', 'name': 'Robinhood Markets, Inc.'},
{'ticker': 'SOFI', 'name': 'SoFi Technologies, Inc.'},
{'ticker': 'ROKU', 'name': 'Roku, Inc.'},
{'ticker': 'DOCU', 'name': 'DocuSign, Inc.'},
{'ticker': 'ZM', 'name': 'Zoom Communications, Inc.'},
{'ticker': 'SPOT', 'name': 'Spotify Technology S.A.'},
{'ticker': 'ETSY', 'name': 'Etsy, Inc.'},
{'ticker': 'EBAY', 'name': 'eBay Inc.'},
{'ticker': 'BABA', 'name': 'Alibaba Group Holding Limited'},
{'ticker': 'JD', 'name': 'JD.com, Inc.'},
{'ticker': 'BIDU', 'name': 'Baidu, Inc.'},
{'ticker': 'SONY', 'name': 'Sony Group Corporation'},
{'ticker': 'SAP', 'name': 'SAP SE'},
{'ticker': 'ASML', 'name': 'ASML Holding N.V.'},
{'ticker': 'ARM', 'name': 'Arm Holdings plc'},
{'ticker': 'TM', 'name': 'Toyota Motor Corporation'},
{'ticker': 'SON', 'name': 'Sonoco Products Company'},
{'ticker': 'DE', 'name': 'Deere & Company'},
{'ticker': 'RTX', 'name': 'RTX Corporation'},
{'ticker': 'LMT', 'name': 'Lockheed Martin Corporation'},
{'ticker': 'HON', 'name': 'Honeywell International Inc.'},
{'ticker': 'UPS', 'name': 'United Parcel Service, Inc.'},
{'ticker': 'FDX', 'name': 'FedEx Corporation'},
{'ticker': 'UAL', 'name': 'United Airlines Holdings, Inc.'},
{'ticker': 'DAL', 'name': 'Delta Air Lines, Inc.'},
{'ticker': 'MAR', 'name': 'Marriott International, Inc.'},
{'ticker': 'BKNG', 'name': 'Booking Holdings Inc.'},
{'ticker': 'EXPE', 'name': 'Expedia Group, Inc.'},
{'ticker': 'CVS', 'name': 'CVS Health Corporation'},
{'ticker': 'CI', 'name': 'The Cigna Group'},
{'ticker': 'MDT', 'name': 'Medtronic plc'},
{'ticker': 'SYK', 'name': 'Stryker Corporation'},
{'ticker': 'GILD', 'name': 'Gilead Sciences, Inc.'},
{'ticker': 'REGN', 'name': 'Regeneron Pharmaceuticals, Inc.'},
{'ticker': 'VRTX', 'name': 'Vertex Pharmaceuticals Incorporated'},
{'ticker': 'CVNA', 'name': 'Carvana Co.'},
{'ticker': 'DKNG', 'name': 'DraftKings Inc.'},
{'ticker': 'RBLX', 'name': 'Roblox Corporation'},
{'ticker': 'EA', 'name': 'Electronic Arts Inc.'},
{'ticker': 'TTWO', 'name': 'Take-Two Interactive Software, Inc.'}
];

View file

@ -1,16 +1,20 @@
import 'package:capstone_project/pages/home.dart'; import 'package:capstone_project/pages/home.dart';
import 'package:capstone_project/pages/settings.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/settings.dart';
import 'package:capstone_project/providers/watchlist.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
SettingsProvider _settingsProvider = SettingsProvider(); SettingsProvider _settingsProvider = SettingsProvider();
WatchlistProvider _watchlistProvider = WatchlistProvider();
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await _settingsProvider.load(); await _settingsProvider.load();
await _watchlistProvider.load();
runApp(const MyApp()); runApp(const MyApp());
} }
@ -24,10 +28,14 @@ class MyApp extends StatelessWidget {
return MultiProvider( return MultiProvider(
providers: [ providers: [
ChangeNotifierProvider.value(value: _settingsProvider), ChangeNotifierProvider.value(value: _settingsProvider),
ChangeNotifierProvider.value(value: _watchlistProvider),
], ],
child: ShadcnApp.router( child: ShadcnApp.router(
scaling: const AdaptiveScaling(0.9),
theme: ThemeData( theme: ThemeData(
colorScheme: ColorSchemes.darkRose colorScheme: ColorSchemes.darkSlate,
density: Density.spaciousDensity,
radius: 0.5,
), ),
routerConfig: _routerConfig, routerConfig: _routerConfig,
), ),
@ -39,5 +47,6 @@ GoRouter _routerConfig = GoRouter(
routes: [ routes: [
HomePage.route, HomePage.route,
SettingsPage.route, SettingsPage.route,
StockDashboardPage.route,
] ]
); );

View file

@ -0,0 +1,120 @@
import "package:capstone_project/utils/agrigator.dart";
// raised when deserializing a signal payload that predates the direction/impact
// rework. the loader catches this and skips the signal.
class LegacySignalException implements Exception {
final String message;
LegacySignalException(this.message);
@override
String toString() => "LegacySignalException: $message";
}
class EventSignal {
final String eventId;
final String eventSummary;
// positive | negative | neutral
final String direction;
// forecasting | reactive
final String nature;
// likelihood the event is real / actually happened as reported (0..1)
final double probability;
// magnitude of expected immediate price reaction, assuming the event is real (0..1)
final double impact;
final String rationale;
final List<FeedItem> articles;
final DateTime createdAt;
EventSignal({
required this.eventId,
required this.eventSummary,
required this.direction,
this.nature = "reactive",
required this.probability,
required this.impact,
required this.rationale,
required this.articles,
DateTime? createdAt,
}) : createdAt = createdAt ?? DateTime.now();
factory EventSignal.fromJson(
Map<String, dynamic> json,
List<FeedItem> articles, {
String? eventIdOverride,
}) {
// legacy schema discriminator old signals had `signal` + `confidence`
// and no `impact`/`direction`. those numbers meant something different
// (or nothing) so we refuse to load them instead of silently remapping.
final hasImpact = json.containsKey("impact");
final hasDirection = json.containsKey("direction");
if (!hasImpact || !hasDirection) {
throw LegacySignalException(
"payload missing impact/direction — incompatable with current schema",
);
}
return EventSignal(
eventId: eventIdOverride ?? (json["event_id"] ?? "").toString(),
eventSummary: (json["event_summary"] ?? "").toString(),
direction: _normalizeDirection((json["direction"] ?? "").toString()),
nature: _normalizeNature((json["nature"] ?? "").toString()),
probability: _normalizeScore(json["probability"]),
impact: _normalizeScore(json["impact"]),
rationale: (json["rationale"] ?? "").toString(),
articles: articles,
createdAt: DateTime.tryParse((json["created_at"] ?? "").toString()),
);
}
Map<String, dynamic> toJson() {
return {
"event_id": eventId,
"event_summary": eventSummary,
"direction": direction,
"nature": nature,
"probability": probability,
"impact": impact,
"rationale": rationale,
"articles": articles.map((article) => article.toJson()).toList(),
"created_at": createdAt.toIso8601String(),
};
}
static String _normalizeNature(String value) {
return value.trim().toLowerCase() == "forecasting" ? "forecasting" : "reactive";
}
static String _normalizeDirection(String value) {
switch (value.trim().toLowerCase()) {
case "positive":
return "positive";
case "negative":
return "negative";
default:
return "neutral";
}
}
static double _normalizeScore(dynamic value) {
if (value is num) {
return value.toDouble().clamp(0.0, 1.0);
}
if (value is String) {
final parsed = double.tryParse(value);
if (parsed != null) {
return parsed.clamp(0.0, 1.0);
}
}
return 0.0;
}
}

View file

@ -0,0 +1,61 @@
import "package:capstone_project/models/event_signal.dart";
import "package:capstone_project/utils/agrigator.dart";
class WatchedStock {
final String ticker;
final String companyName;
EventSignal? latestSignal;
List<EventSignal> signalHistory;
WatchedStock({
required this.ticker,
required this.companyName,
this.latestSignal,
List<EventSignal>? signalHistory,
}) : signalHistory = signalHistory ?? [];
factory WatchedStock.fromJson(Map<String, dynamic> json) {
final signalHistoryJson = (json["signalHistory"] as List? ?? [])
.whereType<Map<String, dynamic>>()
.map(_eventSignalFromStoredJson)
.whereType<EventSignal>()
.toList();
final latestSignalJson = json["latestSignal"];
final latest = latestSignalJson is Map<String, dynamic>
? _eventSignalFromStoredJson(latestSignalJson)
: null;
return WatchedStock(
ticker: (json["ticker"] ?? "").toString(),
companyName: (json["companyName"] ?? "").toString(),
latestSignal: latest ?? (signalHistoryJson.isNotEmpty ? signalHistoryJson.first : null),
signalHistory: signalHistoryJson,
);
}
Map<String, dynamic> toJson() {
return {
"ticker": ticker,
"companyName": companyName,
"latestSignal": latestSignal?.toJson(),
"signalHistory": signalHistory.map((signal) => signal.toJson()).toList(),
};
}
// returns null (and logs) when the stored payload is from the pre-rework
// schema we'd rather drop it than invent impact/direction values.
static EventSignal? _eventSignalFromStoredJson(Map<String, dynamic> json) {
final articles = (json["articles"] as List? ?? [])
.whereType<Map<String, dynamic>>()
.map(FeedItem.fromJson)
.toList();
try {
return EventSignal.fromJson(json, articles);
} on LegacySignalException catch (e) {
print("[watched_stock] dropping legacy signal: ${e.message}");
return null;
}
}
}

View file

@ -1,136 +1,359 @@
import 'package:capstone_project/data/stock_list.dart';
import 'dart:convert'; import 'package:capstone_project/models/watched_stock.dart';
import 'dart:io'; import 'package:capstone_project/providers/watchlist.dart';
import 'package:capstone_project/services/stock_price_service.dart';
import 'package:capstone_project/providers/settings.dart'; import 'package:capstone_project/widgets/app_shell.dart';
import 'package:capstone_project/utils/agrigator.dart'; import 'package:capstone_project/widgets/stock_price_chart.dart';
import 'package:capstone_project/widgets/navbar.dart';
import 'package:go_router/go_router.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'; import 'package:shadcn_flutter/shadcn_flutter.dart';
class HomePage extends StatelessWidget { class HomePage extends StatelessWidget {
const HomePage({super.key});
static GoRoute route = GoRoute( static GoRoute route = GoRoute(
path: "/", path: "/",
builder: (context, state) => HomePage() builder: (context, state) => const HomePage(),
); );
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// TODO: implement build return Consumer<WatchlistProvider>(
return Scaffold( builder: (context, watchlist, _) {
headers: [ final stocks = watchlist.stocks;
AppBar()
return AugorShell(
titleTag: 'Watchlist',
headerLeading: [
_AppName(),
], ],
footers: [ headerTrailing: [
ProjNavBar( Padding(
currentPage: "home", padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
) child: Button.primary(
onPressed: () => showDialog(
context: context,
builder: (context) => const AddStockDialog(),
),
child: const Text('Add stock'),
),
),
], ],
child: Center( statusLeft: StatusText('${stocks.length} stock${stocks.length == 1 ? '' : 's'} tracked'),
child: stocks.isEmpty
? Center(
child: SizedBox(
width: 520,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text('Your watchlist is empty').h2,
Text( const Gap(8),
"Nothing here is final yet!", Text('Add a stock to start tracking signals and news.').muted,
).h1, const Gap(16),
Gap(16),
Button.primary( Button.primary(
onPressed: () async { onPressed: () => showDialog(
print("Aggregating feeds..."); context: context,
SettingsProvider settings = SettingsProvider.of(context); builder: (context) => const AddStockDialog(),
),
// Fetch all feeds child: const Text('Add your first stock'),
List<Uri> feedUris = settings.feeds.map((feed) => Uri.parse(feed.url)).toList(); ),
List<FeedItem> aggregatedItems = await fetchFeeds(feedUris); ],
).withPadding(all: 16),
// 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"
), ),
) )
: 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,
],
),
),
);
},
),
),
], ],
), ),
), ),
); );
} }
} }

View file

@ -1,16 +1,16 @@
import 'package:capstone_project/providers/settings.dart'; 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:file_picker/file_picker.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
class SettingsPage extends StatefulWidget {
class SettingsPage extends StatefulWidget {
static GoRoute route = GoRoute( static GoRoute route = GoRoute(
path: "/settings", path: "/settings",
builder: (context, state) => SettingsPage() builder: (context, state) => SettingsPage(),
); );
@override @override
@ -19,431 +19,80 @@ class SettingsPage extends StatefulWidget {
class _SettingsPageState extends State<SettingsPage> { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = SettingsProvider.of(context);
final apiKey = settings.openRouterApiKey;
final storagePath = settings.applicationStorageLocation;
if (_isLoading) { final fields = <PanelField>[
return Scaffold(
child: Center(
child: CircularProgressIndicator(),
),
);
}
return Scaffold( PanelField(
headers: [ section: 'API',
AppBar( label: const Text('OpenRouter key'),
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);
},
),
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( child: TextField(
placeholder: Text( placeholder: const Text('sk-or-...'),
"Select a diretory using Browse" initialValue: apiKey.isEmpty
), ? ''
initialValue: appStorageLocation, : '${apiKey.substring(0, apiKey.length >= 8 ? 8 : apiKey.length)}xxxxxx',
enabled: false, onChanged: (value) => settings.setOpenRouterApiKey(value),
), ),
), ),
Gap(8), 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,
),
overflow: TextOverflow.ellipsis,
),
),
const Gap(8),
Button.outline( Button.outline(
child: Text(
"Browse"
),
onPressed: () { onPressed: () {
FilePicker.platform.getDirectoryPath().then((path) {
FilePicker.platform.getDirectoryPath().then((selectedPath) { if (path != null) {
if (selectedPath != null) { settings.setApplicationStorageLocation(path);
SettingsProvider settings = SettingsProvider.of(context); setState(() {});
settings.setApplicationStorageLocation(selectedPath);
loadPage();
} }
}); });
}, },
child: const Text('Browse'),
), ),
Gap(8), const Gap(4),
IconButton.destructive( IconButton.destructive(
icon: Icon( icon: const Icon(LucideIcons.refreshCcw),
LucideIcons.refreshCcw
),
onPressed: () async { onPressed: () async {
SettingsProvider settings = SettingsProvider.of(context); final dir = await getApplicationDocumentsDirectory();
settings.setApplicationStorageLocation(dir.path);
settings.setApplicationStorageLocation((await getApplicationDocumentsDirectory()).path); setState(() {});
loadPage();
}, },
)
],
), ),
], ],
).withMargin(
all: 10
), ),
), ),
];
return AugorShell(
titleTag: 'Settings',
statusLeft: const StatusText('Augor · Settings'),
child: SingleChildScrollView(
child: PanelList(fields: fields),
), ),
).withPadding(all: 24),
); );
} }
} }
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"
),
),
],
)
],
),
),
),
);
}
}

View file

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

View file

@ -1,115 +1,14 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:uid/uid.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'dart:io'; import 'dart:io';
class SettingsProvider extends ChangeNotifier { class SettingsProvider extends ChangeNotifier {
String _OPENAI_API_KEY = ""; String _openRouterApiKey = "";
String get openAIApiKey => _OPENAI_API_KEY; String get openRouterApiKey => _openRouterApiKey;
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);
late String _applicationStorageLocation; late String _applicationStorageLocation;
String get applicationStorageLocation => _applicationStorageLocation; String get applicationStorageLocation => _applicationStorageLocation;
@ -119,42 +18,13 @@ class SettingsProvider extends ChangeNotifier {
} }
Future<void> load() async { Future<void> load() async {
// get documents directory dynamicly
final Directory appDocDir = await getApplicationDocumentsDirectory(); final Directory appDocDir = await getApplicationDocumentsDirectory();
_applicationStorageLocation = appDocDir.path; _applicationStorageLocation = appDocDir.path;
SharedPreferences prefs = await SharedPreferences.getInstance(); SharedPreferences prefs = await SharedPreferences.getInstance();
_OPENAI_API_KEY = prefs.getString('openai_api_key') ?? _OPENAI_API_KEY; _openRouterApiKey = prefs.getString('openrouter_api_key') ?? prefs.getString('openai_api_key') ?? _openRouterApiKey;
_applicationStorageLocation = prefs.getString('application_storage_location') ?? _applicationStorageLocation;
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;
notifyListeners(); notifyListeners();
} }
@ -162,42 +32,12 @@ class SettingsProvider extends ChangeNotifier {
Future<void> save() async { Future<void> save() async {
SharedPreferences prefs = await SharedPreferences.getInstance(); SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setString('openai_api_key', _OPENAI_API_KEY); await prefs.setString('openrouter_api_key', _openRouterApiKey);
await prefs.setStringList('feeds', _feeds.map((f) => jsonEncode(f.toJson())).toList());
await prefs.setString('application_storage_location', _applicationStorageLocation); await prefs.setString('application_storage_location', _applicationStorageLocation);
} }
void setOpenAIApiKey(String key) { void setOpenRouterApiKey(String key) {
_OPENAI_API_KEY = key; _openRouterApiKey = 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);
save(); save();
notifyListeners(); 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;
}
}

View file

@ -0,0 +1,91 @@
import 'dart:convert';
import 'package:capstone_project/models/event_signal.dart';
import 'package:capstone_project/models/watched_stock.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
class WatchlistProvider extends ChangeNotifier {
static const String _storageKey = 'watched_stocks';
List<WatchedStock> _stocks = [];
List<WatchedStock> get stocks => List.unmodifiable(_stocks);
static WatchlistProvider of(BuildContext context) {
return Provider.of<WatchlistProvider>(context, listen: false);
}
Future<void> load() async {
final prefs = await SharedPreferences.getInstance();
final stockStrings = prefs.getStringList(_storageKey) ?? [];
_stocks = stockStrings.map((stockString) {
return WatchedStock.fromJson(jsonDecode(stockString));
}).toList();
notifyListeners();
}
Future<void> save() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(
_storageKey,
_stocks.map((stock) => jsonEncode(stock.toJson())).toList(),
);
}
Future<void> addStock(String ticker, String companyName) async {
final normalizedTicker = ticker.trim().toUpperCase();
final normalizedCompanyName = companyName.trim();
if (normalizedTicker.isEmpty || normalizedCompanyName.isEmpty) {
return;
}
final existingIndex = _stocks.indexWhere((stock) => stock.ticker == normalizedTicker);
if (existingIndex != -1) {
_stocks[existingIndex] = WatchedStock(
ticker: normalizedTicker,
companyName: normalizedCompanyName,
latestSignal: _stocks[existingIndex].latestSignal,
signalHistory: _stocks[existingIndex].signalHistory,
);
} else {
_stocks.add(
WatchedStock(
ticker: normalizedTicker,
companyName: normalizedCompanyName,
),
);
}
await save();
notifyListeners();
}
Future<void> removeStock(String ticker) async {
_stocks.removeWhere((stock) => stock.ticker == ticker.trim().toUpperCase());
await save();
notifyListeners();
}
Future<void> updateSignals(String ticker, List<EventSignal> signals) async {
final normalizedTicker = ticker.trim().toUpperCase();
final stockIndex = _stocks.indexWhere((stock) => stock.ticker == normalizedTicker);
if (stockIndex == -1) {
return;
}
_stocks[stockIndex] = WatchedStock(
ticker: _stocks[stockIndex].ticker,
companyName: _stocks[stockIndex].companyName,
latestSignal: signals.isNotEmpty ? signals.first : null,
signalHistory: List<EventSignal>.from(signals),
);
await save();
notifyListeners();
}
}

View file

@ -0,0 +1,117 @@
import 'dart:convert';
import 'package:capstone_project/utils/agrigator.dart';
import 'package:http/http.dart' as http;
const _baseUrl = 'http://duriin.imbenji.net';
// pair of (article, semantic distance from the query / seed). distance is
// null when the underlying endpoint doesnt return one.
class SimilarHit {
final FeedItem item;
final double? distance;
SimilarHit({required this.item, this.distance});
}
class DuriinService {
Future<List<FeedItem>> search({
required String ticker,
required String companyName,
required DateTime from,
required DateTime to,
int limit = 100,
}) async {
final fromStr = from.toUtc().toIso8601String();
final toStr = to.toUtc().toIso8601String();
// build the keyword set: the ticker itself plus the company brand name
// (first token of the registered name "NVIDIA Corporation" -> "NVIDIA",
// "Apple Inc" -> "Apple"). the full legal name rarely appears verbatim in
// articles so matching on it would miss most hits.
final keywords = <String>{};
final t = ticker.trim();
if (t.isNotEmpty) keywords.add(t);
final brand = companyName.trim().split(RegExp(r"\s+")).firstOrNull ?? "";
if (brand.isNotEmpty) keywords.add(brand);
final hits = await _fetchHits(queryParams: {
'keyword': keywords.toList(),
'keyword_mode': 'or',
'from': fromStr,
'to': toStr,
'limit': '$limit',
'order': 'newest',
});
final items = hits.map((h) => h.item).toList();
print('Duriin: ${items.length} articles for $ticker / $companyName (kw=${keywords.join(",")})');
return items;
}
// neighbours of a given article via the vector index. used by the event
// clusterer to build real clusters instead of arbitrary time slices.
Future<List<SimilarHit>> findSimilar(int articleId, {int limit = 25}) async {
return _fetchHits(queryParams: {
'similar_to_article': '$articleId',
'limit': '$limit',
});
}
Future<List<SimilarHit>> _fetchHits({required Map<String, dynamic> queryParams}) async {
final uri = Uri.parse(_baseUrl).replace(
path: '/articles',
queryParameters: queryParams,
);
try {
final response = await http.get(uri);
if (response.statusCode != 200) {
print('Duriin: HTTP ${response.statusCode} for $uri');
return [];
}
final body = response.body.trim();
if (body.isEmpty) return [];
final list = jsonDecode(body) as List<dynamic>;
return list.map((raw) {
final m = raw as Map<String, dynamic>;
final id = m['id'] is int
? m['id'] as int
: int.tryParse('${m['id']}');
final item = FeedItem(
id: id,
title: (m['title'] ?? '').toString(),
description: (m['description'] ?? '').toString(),
content: (m['content'] ?? '').toString(),
link: (m['url'] ?? '').toString(),
source: (m['source'] ?? '').toString().isNotEmpty ? m['source'].toString() : null,
pubDate: m['pub_date'] != null ? DateTime.tryParse(m['pub_date'].toString()) : null,
);
final distRaw = m['distance'];
final distance = distRaw is num ? distRaw.toDouble() : null;
return SimilarHit(item: item, distance: distance);
}).where((h) => h.item.title.isNotEmpty && h.item.link.isNotEmpty).toList();
} catch (e) {
print('Duriin fetch error: $e');
return [];
}
}
}

View file

@ -0,0 +1,19 @@
import 'package:capstone_project/services/duriin_service.dart';
import 'package:capstone_project/utils/agrigator.dart';
class NewsSearchService {
final _duriin = DuriinService();
Future<List<FeedItem>> search({
required String ticker,
required String companyName,
required DateTime from,
required DateTime to,
}) => _duriin.search(
ticker: ticker,
companyName: companyName,
from: from,
to: to,
);
}

View file

@ -0,0 +1,82 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class StockPricePoint {
final DateTime date;
final double close;
const StockPricePoint({required this.date, required this.close});
}
class StockPriceService {
Future<List<StockPricePoint>> fetchPriceHistory(
String ticker, {
String range = '1mo',
String interval = '1d',
}) async {
final normalizedTicker = ticker.trim().toUpperCase();
final uri = Uri.parse(
'https://query1.finance.yahoo.com/v8/finance/chart/$normalizedTicker?interval=$interval&range=$range',
);
final response = await http.get(uri, headers: {
'User-Agent': 'Mozilla/5.0',
'Accept': 'application/json',
});
if (response.statusCode != 200) {
throw Exception('Failed to fetch price history for $normalizedTicker: ${response.statusCode}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
final chart = json['chart'] as Map<String, dynamic>?;
final results = chart?['result'] as List?;
if (results == null || results.isEmpty) {
throw Exception('No price history returned for $normalizedTicker');
}
final result = results.first as Map<String, dynamic>;
final timestamps = (result['timestamp'] as List?) ?? const [];
final indicators = result['indicators'] as Map<String, dynamic>?;
final adjCloseSeries = ((indicators?['adjclose'] as List?) ?? const [])
.cast<Map<String, dynamic>?>();
final quoteSeries = ((indicators?['quote'] as List?) ?? const [])
.cast<Map<String, dynamic>?>();
final adjCloseEntry = adjCloseSeries.isNotEmpty ? adjCloseSeries.first : null;
final quoteEntry = quoteSeries.isNotEmpty ? quoteSeries.first : null;
final adjCloseValues = adjCloseEntry == null ? null : adjCloseEntry['adjclose'];
final quoteCloseValues = quoteEntry == null ? null : quoteEntry['close'];
final closes = (adjCloseValues as List?) ?? (quoteCloseValues as List?) ?? const [];
final points = <StockPricePoint>[];
final itemCount = timestamps.length < closes.length ? timestamps.length : closes.length;
for (int i = 0; i < itemCount; i++) {
final timestamp = timestamps[i];
final close = closes[i];
if (timestamp is! num || close is! num) {
continue;
}
points.add(
StockPricePoint(
date: DateTime.fromMillisecondsSinceEpoch(
timestamp.toInt() * 1000,
isUtc: true,
).toLocal(),
close: close.toDouble(),
),
);
}
if (points.isEmpty) {
throw Exception('No valid price points returned for $normalizedTicker');
}
return points;
}
}

View file

@ -1,268 +1,60 @@
import 'dart:math';
import 'package:http/http.dart' as http;
import 'package:xml/xml.dart';
import 'openai.dart';
const String KEYWORDS = "Business news corporate earnings revenue profit stock market trading equity shares NYSE NASDAQ stock prices quarterly results annual reports CEO announcements executive leadership management changes board directors company strategy mergers acquisitions takeovers buyouts partnerships joint ventures business deals IPO initial public offerings venture capital funding investment rounds valuation startup unicorn enterprise technology product launches innovation R&D research development market expansion international business global markets trade agreements tariffs import export supply chain logistics manufacturing production operations facilities factories plants workforce hiring layoffs restructuring downsizing labor unions strikes employee relations workplace compensation benefits corporate governance shareholder activism proxy fights dividends stock buybacks analyst ratings price targets market capitalization revenue growth profit margins EBITDA cash flow debt financing credit ratings bonds corporate strategy competitive advantage market share industry trends sector analysis retail consumer goods e-commerce technology software hardware semiconductors pharmaceuticals biotech healthcare energy oil gas renewables automotive electric vehicles aerospace defense banking financial services insurance real estate construction infrastructure telecommunications media entertainment streaming gaming hospitality travel transportation logistics shipping airlines regulatory compliance antitrust competition policy lawsuits litigation settlements data breaches cybersecurity intellectual property patents trademarks brand value customer acquisition market positioning business models revenue streams profitability sustainability ESG environmental social governance";
List<double>? KEYWORD_EMBEDDINGS;
class FeedItem { class FeedItem {
final int? id;
final String title; final String title;
final String description; 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; final String link;
List<double>? embedding; final String? source;
final DateTime? pubDate;
FeedItem({ FeedItem({
this.id,
required this.title, required this.title,
required this.description, required this.description,
this.content = "",
required this.link, required this.link,
this.embedding, this.source,
this.pubDate,
}); });
@override @override
String toString() { String toString() => 'FeedItem(title: $title, link: $link)';
return "FeedItem(title: $title, link: $link)";
}
FeedItem.fromJson(Map<String, dynamic> json) FeedItem.fromJson(Map<String, dynamic> json)
: title = json["title"], : id = json['id'] is int ? json['id'] as int : int.tryParse('${json['id']}'),
description = json["description"], title = json['title'] ?? '',
link = json["link"], description = json['description'] ?? '',
embedding = json["embedding"] != null content = (json['content'] ?? '').toString(),
? (json["embedding"] as List).map<double>((e) => (e as num).toDouble()).toList() link = json['link'] ?? '',
: null; source = json['source'],
pubDate = json['pub_date'] != null ? DateTime.tryParse(json['pub_date']) : null;
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
"title": title, if (id != null) 'id': id,
"description": description, 'title': title,
"link": link, 'description': description,
if (embedding != null) "embedding": embedding, 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 // median pub date of a cluster falls back to now if none have dates
Future<void> generateEmbedding(FeedItem item, String apiKey) async { DateTime medianPubDate(List<FeedItem> articles) {
final openai = OpenAI(apiKey: apiKey); final dates = articles
.where((a) => a.pubDate != null)
.map((a) => a.pubDate!)
.toList()
..sort();
// combine tittle and descriptin if (dates.isEmpty) return DateTime.now();
final textToEmbed = "${item.title} ${item.description}"; return dates[dates.length ~/ 2];
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();
}
}
// 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();
} }

View file

@ -0,0 +1,154 @@
import "package:capstone_project/services/duriin_service.dart";
import "package:capstone_project/utils/agrigator.dart";
// one event cluster built from a seed article + its semantic neighbours.
// distances are measured from the seed; the seed itself has distance 0.
class EventCluster {
final FeedItem seed;
final List<FeedItem> articles;
// article.id -> distance from seed. missing id / missing distance means
// we dont have a number for it (falls back to nulls in the stats).
final Map<int, double> distancesFromSeed;
EventCluster({
required this.seed,
required this.articles,
required this.distancesFromSeed,
});
// convenience: summary stats over the (known) distances in this cluster.
// returns nulls if we have no distances to report (eg a singleton cluster).
({double? min, double? avg, double? max}) distanceStats() {
final vals = distancesFromSeed.values.toList();
if (vals.isEmpty) return (min: null, avg: null, max: null);
double lo = vals.first;
double hi = vals.first;
double sum = 0.0;
for (final v in vals) {
if (v < lo) lo = v;
if (v > hi) hi = v;
sum += v;
}
return (min: lo, avg: sum / vals.length, max: hi);
}
}
class EventClusterer {
final DuriinService _duriin;
// neighbours whose distance from the seed is strictly greater than this
// are dropped as off-topic. calibrated against observed api distances:
// genuinely-same-event pairs land around 0.580.62, different-event-same-
// topic pairs start around 0.70+. tighten if clusters start merging distinct
// events, loosen if obvious same-event stories end up as singletons.
final double distanceThreshold;
// hard cap on articles per cluster keeps prompt size predictable
final int maxClusterSize;
// how many neighbours to ask the api for per seed
final int neighbourFetchLimit;
EventClusterer({
DuriinService? duriin,
this.distanceThreshold = 0.60,
this.maxClusterSize = 10,
this.neighbourFetchLimit = 25,
}) : _duriin = duriin ?? DuriinService();
Future<List<EventCluster>> cluster(List<FeedItem> articles) async {
if (articles.isEmpty) return [];
// index by id for fast membership checks when neighbours come back
final byId = <int, FeedItem>{};
final withoutId = <FeedItem>[];
for (final a in articles) {
if (a.id != null) {
byId[a.id!] = a;
} else {
withoutId.add(a);
}
}
// work through newest first so the first signal surfaced is the freshest
final queue = byId.values.toList()
..sort((a, b) {
final da = a.pubDate ?? DateTime.fromMillisecondsSinceEpoch(0);
final db = b.pubDate ?? DateTime.fromMillisecondsSinceEpoch(0);
return db.compareTo(da);
});
final clustered = <int>{};
final clusters = <EventCluster>[];
for (final seed in queue) {
if (clustered.contains(seed.id)) continue;
final neighbours = await _duriin.findSimilar(
seed.id!,
limit: neighbourFetchLimit,
);
// keep only neighbours we actually fetched (same ticker / window)
// and that are close enough to count as the same event.
final members = <FeedItem>[seed];
final distances = <int, double>{};
// dedupe just in case the api returns the seed in its own neighbour list
final memberIds = <int>{seed.id!};
for (final hit in neighbours) {
final nid = hit.item.id;
if (nid == null) continue;
if (nid == seed.id) continue;
if (memberIds.contains(nid)) continue;
final inWindow = byId[nid];
if (inWindow == null) continue;
final d = hit.distance;
if (d == null) continue;
if (d > distanceThreshold) continue;
members.add(inWindow);
distances[nid] = d;
memberIds.add(nid);
if (members.length >= maxClusterSize) break;
}
for (final id in memberIds) {
clustered.add(id);
}
clusters.add(EventCluster(
seed: seed,
articles: members,
distancesFromSeed: distances,
));
}
// articles with no id (shouldnt happen post-api-update, but just in case)
// each becomes its own singleton cluster so we never silently drop them.
for (final orphan in withoutId) {
clusters.add(EventCluster(
seed: orphan,
articles: [orphan],
distancesFromSeed: const {},
));
}
return clusters;
}
}

View file

@ -1,23 +1,19 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
/// OpenAI API client for Dart /// OpenRouter API client for Dart
class OpenAI { class OpenRouter {
final String apiKey; final String apiKey;
final String baseUrl; final String baseUrl;
final http.Client _client; final http.Client _client;
OpenAI({ OpenRouter({
required this.apiKey, required this.apiKey,
this.baseUrl = 'https://api.openai.com/v1', this.baseUrl = 'https://openrouter.ai/api/v1',
http.Client? client, http.Client? client,
}) : _client = client ?? http.Client(); }) : _client = client ?? http.Client();
/// Access to chat completions API
ChatCompletions get chat => ChatCompletions(this); ChatCompletions get chat => ChatCompletions(this);
/// Access to embeddings API
Embeddings get embeddings => Embeddings(this); Embeddings get embeddings => Embeddings(this);
void dispose() { void dispose() {
@ -25,26 +21,19 @@ class OpenAI {
} }
} }
/// Chat completions API
class ChatCompletions { class ChatCompletions {
final OpenAI _openai; final OpenRouter _openRouter;
ChatCompletions(this._openai); ChatCompletions(this._openRouter);
/// Access to completions endpoint Completions get completions => Completions(_openRouter);
Completions get completions => Completions(_openai);
} }
/// Completions endpoint
class Completions { 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({ Future<dynamic> create({
required String model, required String model,
required List<dynamic> messages, required List<dynamic> messages,
@ -84,17 +73,17 @@ class Completions {
} }
Future<ChatCompletion> _createCompletion(Map<String, dynamic> body) async { Future<ChatCompletion> _createCompletion(Map<String, dynamic> body) async {
final response = await _openai._client.post( final response = await _openRouter._client.post(
Uri.parse('${_openai.baseUrl}/chat/completions'), Uri.parse('${_openRouter.baseUrl}/chat/completions'),
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': 'Bearer ${_openai.apiKey}', 'Authorization': 'Bearer ${_openRouter.apiKey}',
}, },
body: jsonEncode(body), body: jsonEncode(body),
); );
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw OpenAIException( throw OpenRouterException(
statusCode: response.statusCode, statusCode: response.statusCode,
message: response.body, message: response.body,
); );
@ -107,21 +96,21 @@ class Completions {
Map<String, dynamic> body) async* { Map<String, dynamic> body) async* {
final request = http.Request( final request = http.Request(
'POST', 'POST',
Uri.parse('${_openai.baseUrl}/chat/completions'), Uri.parse('${_openRouter.baseUrl}/chat/completions'),
); );
request.headers.addAll({ request.headers.addAll({
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': 'Bearer ${_openai.apiKey}', 'Authorization': 'Bearer ${_openRouter.apiKey}',
}); });
request.body = jsonEncode(body); request.body = jsonEncode(body);
final streamedResponse = await _openai._client.send(request); final streamedResponse = await _openRouter._client.send(request);
if (streamedResponse.statusCode != 200) { if (streamedResponse.statusCode != 200) {
final body = await streamedResponse.stream.bytesToString(); final body = await streamedResponse.stream.bytesToString();
throw OpenAIException( throw OpenRouterException(
statusCode: streamedResponse.statusCode, statusCode: streamedResponse.statusCode,
message: body, message: body,
); );
@ -133,10 +122,10 @@ class Completions {
await for (final line in stream) { await for (final line in stream) {
if (line.isEmpty) continue; if (line.isEmpty) continue;
if (line.startsWith(':')) continue; // Skip comments if (line.startsWith(':')) continue;
if (!line.startsWith('data: ')) continue; if (!line.startsWith('data: ')) continue;
final data = line.substring(6); // Remove 'data: ' prefix final data = line.substring(6);
if (data == '[DONE]') { if (data == '[DONE]') {
break; break;
@ -146,14 +135,12 @@ class Completions {
final json = jsonDecode(data); final json = jsonDecode(data);
yield ChatCompletionChunk.fromJson(json); yield ChatCompletionChunk.fromJson(json);
} catch (e) { } catch (e) {
// Skip malformed chunks
continue; continue;
} }
} }
} }
} }
/// Chat message
class ChatMessage { class ChatMessage {
final String role; final String role;
final String content; final String content;
@ -183,7 +170,6 @@ class ChatMessage {
ChatMessage(role: 'assistant', content: content); ChatMessage(role: 'assistant', content: content);
} }
/// Stream options
class StreamOptions { class StreamOptions {
final bool includeUsage; final bool includeUsage;
@ -194,7 +180,6 @@ class StreamOptions {
}; };
} }
/// Chat completion response (non-streaming)
class ChatCompletion { class ChatCompletion {
final String id; final String id;
final String object; final String object;
@ -224,7 +209,6 @@ class ChatCompletion {
); );
} }
/// Chat completion chunk (streaming)
class ChatCompletionChunk { class ChatCompletionChunk {
final String id; final String id;
final String object; final String object;
@ -255,7 +239,6 @@ class ChatCompletionChunk {
); );
} }
/// Choice in non-streaming response
class Choice { class Choice {
final int index; final int index;
final ChatMessage message; final ChatMessage message;
@ -274,7 +257,6 @@ class Choice {
); );
} }
/// Choice in streaming response
class ChunkChoice { class ChunkChoice {
final int index; final int index;
final Delta? delta; final Delta? delta;
@ -293,7 +275,6 @@ class ChunkChoice {
); );
} }
/// Delta content in streaming chunks
class Delta { class Delta {
final String? role; final String? role;
final String? content; final String? content;
@ -309,7 +290,6 @@ class Delta {
); );
} }
/// Token usage information
class Usage { class Usage {
final int? promptTokens; final int? promptTokens;
final int? completionTokens; final int? completionTokens;
@ -334,32 +314,29 @@ class Usage {
}; };
} }
/// OpenAI API exception class OpenRouterException implements Exception {
class OpenAIException implements Exception {
final int statusCode; final int statusCode;
final String message; final String message;
OpenAIException({ OpenRouterException({
required this.statusCode, required this.statusCode,
required this.message, required this.message,
}); });
@override @override
String toString() => 'OpenAIException($statusCode): $message'; String toString() => 'OpenRouterException($statusCode): $message';
} }
/// Embeddings API
class Embeddings { class Embeddings {
final OpenAI _openai; final OpenRouter _openRouter;
Embeddings(this._openai); Embeddings(this._openRouter);
/// Create embeddings for input text
Future<EmbeddingResponse> create({ Future<EmbeddingResponse> create({
required String model, required String model,
required dynamic input, // String or List<String> required dynamic input,
String? user, String? user,
String? encodingFormat, // 'float' or 'base64' String? encodingFormat,
int? dimensions, int? dimensions,
}) async { }) async {
final body = { final body = {
@ -370,17 +347,17 @@ class Embeddings {
if (dimensions != null) 'dimensions': dimensions, if (dimensions != null) 'dimensions': dimensions,
}; };
final response = await _openai._client.post( final response = await _openRouter._client.post(
Uri.parse('${_openai.baseUrl}/embeddings'), Uri.parse('${_openRouter.baseUrl}/embeddings'),
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': 'Bearer ${_openai.apiKey}', 'Authorization': 'Bearer ${_openRouter.apiKey}',
}, },
body: jsonEncode(body), body: jsonEncode(body),
); );
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw OpenAIException( throw OpenRouterException(
statusCode: response.statusCode, statusCode: response.statusCode,
message: response.body, message: response.body,
); );
@ -390,7 +367,6 @@ class Embeddings {
} }
} }
/// Embedding response
class EmbeddingResponse { class EmbeddingResponse {
final String object; final String object;
final List<Embedding> data; final List<Embedding> data;
@ -406,14 +382,13 @@ class EmbeddingResponse {
factory EmbeddingResponse.fromJson(Map<String, dynamic> json) => factory EmbeddingResponse.fromJson(Map<String, dynamic> json) =>
EmbeddingResponse( EmbeddingResponse(
object: json['object'], object: (json['object'] ?? '').toString(),
data: (json['data'] as List).map((e) => Embedding.fromJson(e)).toList(), data: (json['data'] as List? ?? []).map((e) => Embedding.fromJson(e)).toList(),
model: json['model'], model: (json['model'] ?? '').toString(),
usage: json['usage'] != null ? Usage.fromJson(json['usage']) : null, usage: json['usage'] != null ? Usage.fromJson(json['usage']) : null,
); );
} }
/// Individual embedding
class Embedding { class Embedding {
final String object; final String object;
final int index; final int index;
@ -426,9 +401,10 @@ class Embedding {
}); });
factory Embedding.fromJson(Map<String, dynamic> json) => Embedding( factory Embedding.fromJson(Map<String, dynamic> json) => Embedding(
object: json['object'], object: (json['object'] ?? '').toString(),
index: json['index'], index: (json['index'] ?? 0) as int,
embedding: (json['embedding'] as List).map<double>((e) => (e as num).toDouble()).toList(), embedding: (json['embedding'] as List? ?? [])
.map<double>((e) => (e as num).toDouble())
.toList(),
); );
} }

View file

@ -0,0 +1,26 @@
// bucket 0..1 signal scores (probability, impact) into human labels. the
// llm outputs a continuous number but the ui shouldnt pretend that number
// means much beyond a rough band "12.3%" reads as false precision.
//
// thresholds are deliberately a bit generous at the bottom so that the
// failure-fallback path (0.0) lands clearly in NONE.
enum SignalBucket { none, low, medium, high }
SignalBucket bucketFor(double score) {
if (score < 0.15) return SignalBucket.none;
if (score < 0.40) return SignalBucket.low;
if (score < 0.70) return SignalBucket.medium;
return SignalBucket.high;
}
String bucketLabel(double score) {
switch (bucketFor(score)) {
case SignalBucket.none: return "None";
case SignalBucket.low: return "Low";
case SignalBucket.medium: return "Medium";
case SignalBucket.high: return "High";
}
}

View file

@ -0,0 +1,235 @@
import "dart:convert";
import "package:capstone_project/models/event_signal.dart";
import "package:capstone_project/utils/agrigator.dart";
import "package:capstone_project/utils/event_clusterer.dart";
import "package:capstone_project/utils/openrouter.dart";
// per-article content cap in the prompt. higher = more context, more tokens.
// tuned so an 8-article cluster stays well under 20k input tokens.
const int _kContentCharCap = 1500;
class SignalGenerator {
final String apiKey;
SignalGenerator({required this.apiKey});
Future<List<EventSignal>> generateSignals(
List<EventCluster> clusters, {
required String ticker,
required String companyName,
}) async {
final filtered = clusters.where((c) => c.articles.isNotEmpty).toList();
final results = await Future.wait(
filtered.map(
(c) => generateSignal(c, ticker: ticker, companyName: companyName),
),
);
// sort by impact descending impact is "how much should i care about this"
// probability is the credibility gate, not the headline number
results.sort((a, b) => b.impact.compareTo(a.impact));
return results;
}
Future<EventSignal> generateSignal(
EventCluster cluster, {
required String ticker,
required String companyName,
}) async {
final openRouter = OpenRouter(apiKey: apiKey);
final articles = cluster.articles;
final eventId = _eventIdFor(articles);
try {
final response = await openRouter.chat.completions.create(
model: "openai/gpt-4.1-mini",
messages: [
ChatMessage.system(_systemPrompt).toJson(),
ChatMessage.user(_buildPrompt(cluster, ticker: ticker, companyName: companyName)).toJson(),
],
temperature: 0.2,
) as ChatCompletion;
final rawContent = response.choices.first.message.content.trim();
final parsed = jsonDecode(_extractJson(rawContent)) as Map<String, dynamic>;
final signal = EventSignal.fromJson(parsed, articles, eventIdOverride: eventId);
// override createdAt with median pub date from the article cluster
return EventSignal(
eventId: signal.eventId,
eventSummary: signal.eventSummary,
direction: signal.direction,
nature: signal.nature,
probability: signal.probability,
impact: signal.impact,
rationale: signal.rationale,
articles: signal.articles,
createdAt: medianPubDate(articles),
);
} catch (e) {
print("Error generating signal: $e");
return EventSignal(
eventId: eventId,
eventSummary: _fallbackSummary(articles),
direction: "neutral",
probability: 0.0,
impact: 0.0,
rationale: "Signal generation failed.",
articles: articles,
createdAt: medianPubDate(articles),
);
} finally {
openRouter.dispose();
}
}
static const String _systemPrompt = """
You analyze clusters of business and finance news articles that plausibly concern a single asset (a stock, commodity, etc). Each cluster has been assembled via semantic similarity search the articles are believed to be reporting on the same underlying event, not merely from the same time window. For each cluster you estimate how likely the event is real, how much the asset's price is likely to move in the short term, and the direction of that move.
Return valid JSON only, with exactly these keys:
event_summary, direction, nature, probability, impact, rationale
Field definitions:
- probability (number, 0.01.0): likelihood that the underlying event is real / actually happening as reported. Grounded primarily in coverage number of articles, number of distinct publishers, reputability of those publishers, and how tightly the cluster hangs together semantically (lower avg distance = stronger corroboration). This is NOT the probability that the price moves, and it is NOT conditional on direction. Anchors:
* 0.1 single article, unknown blog, speculative framing, no corroboration
* 0.5 several articles from mid-tier outlets, or mixed/conflicting accounts across publishers
* 0.9 wide coverage: many articles across multiple major wire services or papers of record (Reuters, Bloomberg, AP, WSJ, FT, NYT, etc.) converging on the same core facts
- impact (number, 0.01.0): expected magnitude of the asset's immediate price reaction over roughly the next few trading days, ASSUMING the event is real. Reasoned from what the event actually is, applied to this specific asset. Coverage volume is only a weak prior here — loud news is not the same as impactful news, and long-term significance is not the same as short-term reaction. Anchors (illustrative, oil-related asset):
* 0.1 an OPEC minister makes a vague forward-looking comment about prices
* 0.5 a refinery outage in a secondary producing region; a mid-sized earnings beat
* 0.9 Strait of Hormuz closure; a surprise OPEC+ production cut of material size; a major sanctions announcement hitting the asset's supply or demand
- direction (string, enum): "positive" | "negative" | "neutral" expected directional bias of the immediate price reaction for this specific asset. Kept separate from impact so a large negative and a large positive are both high-impact.
- nature (string, enum): "forecasting" if the cluster is predicting or anticipating a future event, "reactive" if it is reporting on something that already happened.
- event_summary (string): one neutral sentence describing the event itself. No hedging, no direction words.
- rationale (string): a short paragraph (24 sentences) covering two things, in this order:
1. What's actually happening in the cluster — more substantive than event_summary. Pull out the concrete facts: who did what, specific numbers, quoted figures, timelines, named actors. This is the reader learning what the news IS.
2. The causal chain from event price reaction for THIS asset. Why does this move the asset in the chosen direction, and why by the chosen magnitude? Reference the mechanism (supply, demand, competition, margins, guidance, sentiment, regulatory exposure, etc).
DO NOT restate probability, the nature label, publisher count, source reputability, corroboration strength, or semantic tightness. All of that is shown in the UI alongside the rationale repeating it wastes the only place the reader learns anything new. Focus on event substance and causal reasoning, not meta-commentary about the input data.
Important:
* probability is about the event being real, not about price movement.
* impact is about magnitude of short-term reaction, not long-term significance.
* direction is separate from impact.
* Return only the JSON object, no prose, no code fences.
""";
String _buildPrompt(
EventCluster cluster, {
required String ticker,
required String companyName,
}) {
final articles = cluster.articles;
final publishers = articles
.map((a) => (a.source ?? "").trim())
.where((s) => s.isNotEmpty)
.toSet()
.toList();
final buffer = StringBuffer();
buffer.writeln("Asset: $companyName ($ticker)");
buffer.writeln();
buffer.writeln("Coverage stats (computed, do not recount):");
buffer.writeln(" Articles: ${articles.length}");
buffer.writeln(" Distinct publishers: ${publishers.length}");
if (publishers.isNotEmpty) {
buffer.writeln(" Publishers: ${publishers.join(", ")}");
} else {
buffer.writeln(" Publishers: (none identified)");
}
final stats = cluster.distanceStats();
if (stats.min != null) {
buffer.writeln(
" Semantic tightness (distance from seed, 0=identical): "
"min ${stats.min!.toStringAsFixed(3)}, "
"avg ${stats.avg!.toStringAsFixed(3)}, "
"max ${stats.max!.toStringAsFixed(3)}",
);
} else {
buffer.writeln(" Semantic tightness: singleton cluster (no neighbours)");
}
buffer.writeln();
buffer.writeln("Articles:");
for (int i = 0; i < articles.length; i++) {
final article = articles[i];
buffer.writeln("${i + 1}. Title: ${article.title}");
if ((article.source ?? "").trim().isNotEmpty) {
buffer.writeln(" Publisher: ${article.source}");
}
final desc = article.description.trim();
if (desc.isNotEmpty) {
buffer.writeln(" Description: $desc");
}
final body = _clipContent(article.content);
if (body.isNotEmpty) {
buffer.writeln(" Content: $body");
}
buffer.writeln(" Link: ${article.link}");
}
buffer.writeln();
buffer.writeln("Return a single JSON object with keys: event_summary, direction, nature, probability, impact, rationale.");
return buffer.toString();
}
// trim + truncate article body to the char cap. returns empty string if
// theres nothing useful to include.
String _clipContent(String content) {
final trimmed = content.trim();
if (trimmed.isEmpty) return "";
if (trimmed.length <= _kContentCharCap) {
return trimmed;
}
return "${trimmed.substring(0, _kContentCharCap)}...";
}
String _extractJson(String content) {
final start = content.indexOf("{");
final end = content.lastIndexOf("}");
if (start == -1 || end == -1 || end < start) {
throw const FormatException("No JSON object found in model response.");
}
return content.substring(start, end + 1);
}
String _fallbackSummary(List<FeedItem> articles) {
if (articles.isEmpty) {
return "Unknown event";
}
return articles.first.title;
}
// deterministic id from the sorted link set same cluster re-run produces
// the same id, which is handy for dedupe later.
String _eventIdFor(List<FeedItem> articles) {
final links = articles.map((a) => a.link).toList()..sort();
final joined = links.join("|");
final h = joined.hashCode & 0x7FFFFFFF;
return "evt_${h.toRadixString(36)}";
}
}

251
lib/widgets/app_shell.dart Normal file
View file

@ -0,0 +1,251 @@
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
Color contentBgColor(BuildContext context) {
final theme = Theme.of(context);
final h = HSLColor.fromColor(theme.colorScheme.border).hue;
final dark = theme.brightness == Brightness.dark;
return dark
? HSLColor.fromAHSL(1, h, 0.35, 0.13).toColor()
: HSLColor.fromAHSL(1, h, 0.30, 0.88).toColor();
}
// The main shell. replaces Scaffold in all pages.
class AugorShell extends StatelessWidget {
const AugorShell({
super.key,
required this.child,
required this.titleTag,
this.headerLeading = const [],
this.headerTrailing = const [],
this.statusLeft,
this.statusRight,
});
final Widget child;
final String titleTag;
final List<Widget> headerLeading;
final List<Widget> headerTrailing;
final Widget? statusLeft;
final Widget? statusRight;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final monoStyle = GoogleFonts.ibmPlexMono(
fontSize: 11,
height: 1,
fontWeight: FontWeight.w600,
color: theme.colorScheme.mutedForeground,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// header
Container(
decoration: BoxDecoration(
color: theme.colorScheme.background,
border: Border(bottom: BorderSide(color: theme.colorScheme.border, width: 1)),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(left: 12, top: 4, bottom: 4),
child: Row(
children: headerLeading,
),
),
const Spacer(),
...headerTrailing,
// title tag box
Container(
color: theme.colorScheme.border,
constraints: const BoxConstraints(minWidth: 100),
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 10),
child: RichText(
text: TextSpan(text: titleTag.toUpperCase(), style: monoStyle),
),
),
const SizedBox(width: 64), // mac traffic lights gap
],
),
),
),
// content
Expanded(
child: Stack(
fit: StackFit.expand,
children: [
ColoredBox(
color: contentBgColor(context),
child: child,
),
const Positioned.fill(
child: IgnorePointer(
child: CustomPaint(painter: _InsetShadowPainter()),
),
),
],
),
),
// footer / status bar
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: theme.colorScheme.background,
border: Border(top: BorderSide(color: theme.colorScheme.border, width: 1)),
),
child: Row(
children: [
Expanded(
child: statusLeft ?? const SizedBox.shrink(),
),
Expanded(
child: Center(
child: _NavItems(),
),
),
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: statusRight ?? const SizedBox.shrink(),
),
),
],
),
),
],
);
}
}
// shared footer nav home and settings links
class _NavItems extends StatelessWidget {
@override
Widget build(BuildContext context) {
final location = GoRouterState.of(context).uri.toString();
final theme = Theme.of(context);
final monoStyle = GoogleFonts.ibmPlexMono(
fontSize: 11,
height: 1,
fontWeight: FontWeight.w600,
);
Widget navItem(String label, String path) {
final active = location == path || (path == '/' && location == '/');
return GestureDetector(
onTap: () => GoRouter.of(context).go(path),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: active ? theme.colorScheme.border : Colors.transparent,
borderRadius: BorderRadius.circular(3),
),
child: RichText(
text: TextSpan(
text: label,
style: monoStyle.copyWith(
color: active
? theme.colorScheme.foreground
: theme.colorScheme.mutedForeground,
),
),
),
),
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
navItem('Home', '/'),
const SizedBox(width: 2),
navItem('Settings', '/settings'),
],
);
}
}
// footer status text helper use this for statusLeft / statusRight
class StatusText extends StatelessWidget {
const StatusText(this.text, {super.key});
final String text;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return RichText(
text: TextSpan(
text: text,
style: GoogleFonts.ibmPlexMono(
fontSize: 11,
height: 1,
fontWeight: FontWeight.w600,
color: theme.colorScheme.mutedForeground,
),
),
);
}
}
class _InsetShadowPainter extends CustomPainter {
const _InsetShadowPainter();
@override
void paint(Canvas canvas, Size size) {
const blur = 12.0;
final rect = Offset.zero & size;
canvas.save();
canvas.clipRect(rect);
final innerRect = rect.deflate(24);
final ringClip = Path()
..addRect(rect)
..addRect(innerRect)
..fillType = PathFillType.evenOdd;
canvas.clipPath(ringClip);
final path = Path()
..addRect(rect.inflate(blur * 2))
..addRect(rect)
..fillType = PathFillType.evenOdd;
final paint = Paint()
..color = const Color(0x55000000)
..maskFilter = const MaskFilter.blur(BlurStyle.normal, blur);
canvas.drawPath(path, paint);
canvas.restore();
}
@override
bool shouldRepaint(covariant CustomPainter old) => false;
}

View file

@ -0,0 +1,318 @@
import 'package:capstone_project/models/event_signal.dart';
import 'package:capstone_project/utils/signal_buckets.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
class EventSignalCard extends StatelessWidget {
final EventSignal signal;
const EventSignalCard({super.key, required this.signal});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final signalColor = switch (signal.direction) {
'positive' => const Color(0xFF16a34a),
'negative' => const Color(0xFFdc2626),
_ => theme.colorScheme.mutedForeground,
};
final signalBg = switch (signal.direction) {
'positive' => const Color(0xFF16a34a).withValues(alpha: 0.12),
'negative' => const Color(0xFFdc2626).withValues(alpha: 0.12),
_ => theme.colorScheme.border.withValues(alpha: 0.4),
};
return Container(
decoration: BoxDecoration(
color: theme.colorScheme.background.withValues(alpha: 0.8),
border: Border.all(color: theme.colorScheme.border, width: 1),
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.12),
blurRadius: 4,
spreadRadius: 2,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// header row
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: theme.colorScheme.border, width: 1)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(signal.eventSummary).semiBold,
const Gap(4),
RichText(
text: TextSpan(
text: _formatDate(signal.createdAt),
style: GoogleFonts.ibmPlexMono(
fontSize: 10,
height: 1,
fontWeight: FontWeight.w500,
color: theme.colorScheme.mutedForeground,
),
),
),
],
),
),
const Gap(12),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: theme.colorScheme.border.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: theme.colorScheme.border, width: 1),
),
child: RichText(
text: TextSpan(
text: signal.nature,
style: GoogleFonts.ibmPlexMono(
fontSize: 10,
height: 1,
fontWeight: FontWeight.w600,
color: theme.colorScheme.mutedForeground,
),
),
),
),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: signalBg,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: signalColor.withValues(alpha: 0.3), width: 1),
),
child: RichText(
text: TextSpan(
text: signal.direction,
style: GoogleFonts.ibmPlexMono(
fontSize: 10,
height: 1,
fontWeight: FontWeight.w600,
color: signalColor,
),
),
),
),
],
),
],
),
),
// probability + impact as bucketed labels. the raw percentages
// pretended to more precision than the llm actually gives us.
Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 0),
child: Row(
children: [
_BucketRow(
label: 'CREDIBILITY',
bucket: bucketLabel(signal.probability),
color: signalColor,
),
const SizedBox(width: 20),
_BucketRow(
label: 'IMPACT',
bucket: bucketLabel(signal.impact),
color: signalColor,
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 0),
child: Text(signal.rationale).small,
),
// articles
if (signal.articles.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 0),
child: Divider(color: Theme.of(context).colorScheme.border, height: 1),
),
Padding(
padding: const EdgeInsets.fromLTRB(14, 8, 14, 0),
child: RichText(
text: TextSpan(
text: 'SOURCES ${signal.articles.length}',
style: GoogleFonts.ibmPlexMono(
fontSize: 10,
height: 1,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.mutedForeground,
),
),
),
),
for (final article in signal.articles)
_ArticleLink(article: article),
],
const SizedBox(height: 14),
],
),
);
}
}
class _ArticleLink extends StatefulWidget {
final dynamic article; // FeedItem
const _ArticleLink({required this.article});
@override
State<_ArticleLink> createState() => _ArticleLinkState();
}
class _ArticleLinkState extends State<_ArticleLink> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hasLink = widget.article.link.isNotEmpty;
return MouseRegion(
cursor: hasLink ? SystemMouseCursors.click : MouseCursor.defer,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: hasLink
? () => launchUrl(Uri.parse(widget.article.link), mode: LaunchMode.externalApplication)
: null,
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 6, 14, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(
LucideIcons.externalLink,
size: 10,
color: _hovered
? theme.colorScheme.foreground
: theme.colorScheme.mutedForeground,
),
),
const SizedBox(width: 6),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.article.title,
style: TextStyle(
fontSize: 11,
color: _hovered
? theme.colorScheme.foreground
: theme.colorScheme.mutedForeground,
decoration: _hovered ? TextDecoration.underline : TextDecoration.none,
decorationColor: theme.colorScheme.mutedForeground,
),
),
if (widget.article.source != null) ...[
const SizedBox(height: 2),
RichText(
text: TextSpan(
text: widget.article.source,
style: GoogleFonts.ibmPlexMono(
fontSize: 9,
height: 1,
fontWeight: FontWeight.w500,
color: theme.colorScheme.mutedForeground.withValues(alpha: 0.6),
),
),
),
],
],
),
),
],
),
),
),
);
}
}
// small two-piece label: "LABEL Value". kept compact so we can stack
// credibility and impact on one line without the card feeling cramped.
class _BucketRow extends StatelessWidget {
final String label;
final String bucket;
final Color color;
const _BucketRow({
required this.label,
required this.bucket,
required this.color,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
RichText(
text: TextSpan(
text: label,
style: GoogleFonts.ibmPlexMono(
fontSize: 10,
height: 1,
fontWeight: FontWeight.w600,
color: theme.colorScheme.mutedForeground,
),
),
),
const SizedBox(width: 8),
RichText(
text: TextSpan(
text: bucket.toUpperCase(),
style: GoogleFonts.ibmPlexMono(
fontSize: 10,
height: 1,
fontWeight: FontWeight.w700,
color: color,
),
),
),
],
);
}
}
String _formatDate(DateTime dt) {
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
final h = dt.hour.toString().padLeft(2, '0');
final m = dt.minute.toString().padLeft(2, '0');
return '${months[dt.month - 1]} ${dt.day}, ${dt.year} $h:$m';
}

View file

@ -1,53 +1 @@
// deprecated navigation is now handled by AugorShell footer
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
),
)
],
);
}
}

View file

@ -0,0 +1,239 @@
import "dart:math" as math;
import "package:shadcn_flutter/shadcn_flutter.dart";
class PanelField {
const PanelField({
required this.section,
required this.label,
required this.child,
this.enabled = true,
});
final String section;
final Widget label;
final Widget child;
final bool enabled;
}
// auto groups fields by section string
class PanelList extends StatelessWidget {
const PanelList({super.key, required this.fields});
final List<PanelField> fields;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final Map<String, List<PanelField>> grouped = {};
for (final f in fields) {
grouped.putIfAbsent(f.section, () => []).add(f);
}
final entries = grouped.entries.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (int i = 0; i < entries.length; i++) ...[
PanelSection(
title: entries[i].key,
children: entries[i].value
.map((f) => PanelRow(label: f.label, child: f.child, enabled: f.enabled))
.toList(),
),
if (i < entries.length - 1)
Divider(color: theme.colorScheme.border, height: 1),
],
Divider(color: theme.colorScheme.border, height: 1),
const SizedBox(height: 10),
],
);
}
}
class PanelSection extends StatefulWidget {
const PanelSection({
super.key,
required this.title,
required this.children,
});
final String title;
final List<Widget> children;
@override
State<PanelSection> createState() => _PanelSectionState();
}
class _PanelSectionState extends State<PanelSection> {
bool _expanded = true;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
behavior: HitTestBehavior.opaque,
child: ColoredBox(
color: theme.colorScheme.secondary,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [
AnimatedRotation(
turns: _expanded ? 0.0 : -0.25,
duration: const Duration(milliseconds: 120),
child: Icon(LucideIcons.chevronDown, size: 12, color: theme.colorScheme.mutedForeground),
),
const SizedBox(width: 4),
Text(
widget.title,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: theme.colorScheme.foreground,
letterSpacing: 0.4,
),
),
],
),
),
),
),
Divider(color: theme.colorScheme.border, height: 1),
if (_expanded)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (int i = 0; i < widget.children.length; i++) ...[
widget.children[i],
if (i < widget.children.length - 1)
Divider(color: theme.colorScheme.border, height: 1),
],
],
),
],
);
}
}
class PanelRow extends StatelessWidget {
const PanelRow({super.key, required this.label, required this.child, this.enabled = true});
final Widget label;
final Widget child;
final bool enabled;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ConstrainedBox(
constraints: const BoxConstraints(minHeight: 38),
child: IntrinsicHeight(
child: Padding(
padding: const EdgeInsets.only(left: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: 120,
child: Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: DefaultTextStyle.merge(
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.mutedForeground,
decoration: !enabled ? TextDecoration.lineThrough : null,
decorationThickness: !enabled ? 3 : null,
),
child: label,
),
),
),
),
VerticalDivider(color: theme.colorScheme.border, width: 1),
Expanded(
child: Stack(
children: [
Positioned.fill(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: child,
),
),
if (!enabled)
Positioned.fill(
child: GestureDetector(
onTap: () {},
behavior: HitTestBehavior.opaque,
child: CustomPaint(
painter: _DisabledStripePainter(theme.colorScheme.border),
),
),
),
],
),
),
],
),
),
),
);
}
}
class _DisabledStripePainter extends CustomPainter {
const _DisabledStripePainter(this.color);
final Color color;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color.withValues(alpha: 1)
..strokeWidth = 1.0
..style = PaintingStyle.stroke;
const spacing = 4.0;
final diag = math.sqrt(size.width * size.width + size.height * size.height);
final count = (diag / spacing).ceil() + 2;
canvas.save();
canvas.clipRect(Offset.zero & size);
canvas.drawRect(Rect.fromLTWH(1, 1, size.width - 3, size.height - 2), paint);
canvas.translate(0, size.height);
canvas.rotate(-math.pi / 4);
for (int i = -count; i <= count; i++) {
final x = i * spacing;
canvas.drawLine(Offset(x, -diag), Offset(x, diag), paint);
}
canvas.restore();
}
@override
bool shouldRepaint(_DisabledStripePainter old) => old.color != color;
}

View file

@ -0,0 +1,214 @@
import "dart:math";
import "package:capstone_project/models/event_signal.dart";
import "package:capstone_project/utils/signal_buckets.dart";
import "package:fl_chart/fl_chart.dart";
import "package:google_fonts/google_fonts.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
const Color _growthColor = Color(0xFF16a34a);
const Color _declineColor = Color(0xFFdc2626);
const Color _neutralColor = Color(0xFF888888);
// one day in milliseconds used to convert between DateTime and the chart's x axis
const double _msPerDay = 1000 * 60 * 60 * 24;
// Scatter of signals: X is the signal date, Y is the impact, color is direction,
// opacity scales with probability (low-credibility signals fade into the back).
// No line connecting them signals are discrete events, not a time series.
class SignalProbabilityChart extends StatelessWidget {
final List<EventSignal> signals;
final double height;
final bool compact;
const SignalProbabilityChart({
super.key,
required this.signals,
this.height = 200,
this.compact = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (signals.isEmpty) {
return Container(
width: double.infinity,
height: height,
decoration: BoxDecoration(
color: theme.colorScheme.secondary,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: theme.colorScheme.border, width: 1),
),
alignment: Alignment.center,
child: Text("No signal history yet").muted.small,
);
}
// sort so index ordering lines up with the spots list (used for tooltip lookup)
final sorted = [...signals]..sort((a, b) => a.createdAt.compareTo(b.createdAt));
final xs = sorted.map((s) => _toDays(s.createdAt)).toList();
final rawMinX = xs.reduce(min);
final rawMaxX = xs.reduce(max);
// keep a minimum visual span so a single-date cluster doesn't collapse the axis
final span = (rawMaxX - rawMinX) < 1.0 ? 1.0 : (rawMaxX - rawMinX);
final xPad = span * 0.06;
final minX = rawMinX - xPad;
final maxX = rawMaxX + xPad;
final spots = <ScatterSpot>[];
for (final s in sorted) {
final base = _directionColor(s.direction);
// probability drives the alpha: 0.0 0.30, 1.0 0.95
final alpha = 0.30 + (s.probability.clamp(0.0, 1.0) * 0.65);
spots.add(
ScatterSpot(
_toDays(s.createdAt),
s.impact,
dotPainter: FlDotCirclePainter(
radius: 5,
color: base.withValues(alpha: alpha),
strokeWidth: 1,
strokeColor: theme.colorScheme.background,
),
),
);
}
final labelStyle = GoogleFonts.ibmPlexMono(
fontSize: 10,
height: 1,
fontWeight: FontWeight.w500,
color: theme.colorScheme.mutedForeground,
);
return SizedBox(
width: double.infinity,
height: height,
child: ScatterChart(
ScatterChartData(
minX: minX,
maxX: maxX,
minY: 0,
maxY: 1,
scatterSpots: spots,
gridData: FlGridData(
show: !compact,
drawVerticalLine: false,
horizontalInterval: 0.25,
getDrawingHorizontalLine: (_) => FlLine(
color: theme.colorScheme.border,
strokeWidth: 1,
),
),
borderData: FlBorderData(
show: !compact,
border: Border.all(color: theme.colorScheme.border, width: 1),
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: !compact,
reservedSize: 22,
interval: _xTickInterval(minX, maxX),
getTitlesWidget: (value, meta) {
final d = DateTime.fromMillisecondsSinceEpoch(
(value * _msPerDay).round(),
);
return Padding(
padding: const EdgeInsets.only(top: 4),
child: RichText(text: TextSpan(text: _shortDate(d), style: labelStyle)),
);
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: !compact,
reservedSize: 42,
interval: 0.25,
getTitlesWidget: (value, meta) => RichText(
text: TextSpan(
text: "${(value * 100).round()}%",
style: labelStyle,
),
),
),
),
),
scatterTouchData: compact
? ScatterTouchData(enabled: false)
: ScatterTouchData(
enabled: true,
touchTooltipData: ScatterTouchTooltipData(
getTooltipColor: (_) => theme.colorScheme.background,
tooltipBorder: BorderSide(color: theme.colorScheme.border, width: 1),
tooltipPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
maxContentWidth: 220,
getTooltipItems: (ScatterSpot touched) {
// match back to the source signal by x+y ScatterSpot equality
// isn't guaranteed to line up with our original instances
final idx = sorted.indexWhere((s) =>
_toDays(s.createdAt) == touched.x && s.impact == touched.y);
if (idx < 0) return null;
final s = sorted[idx];
return ScatterTooltipItem(
"${_shortDate(s.createdAt)}\n"
"impact ${(s.impact * 100).toStringAsFixed(0)}% · ${s.direction}\n"
"probability ${(s.probability * 100).toStringAsFixed(0)}%",
textStyle: GoogleFonts.ibmPlexMono(
fontSize: 11,
fontWeight: FontWeight.w600,
color: theme.colorScheme.foreground,
),
textAlign: TextAlign.left,
);
},
),
),
),
),
);
}
}
double _toDays(DateTime d) => d.millisecondsSinceEpoch / _msPerDay;
Color _directionColor(String direction) => switch (direction) {
"positive" => _growthColor,
"negative" => _declineColor,
_ => _neutralColor,
};
// pick a sensible x-axis tick interval (in days) based on the visible range
double _xTickInterval(double minX, double maxX) {
final days = (maxX - minX).abs();
if (days <= 7) return 1;
if (days <= 30) return 7;
if (days <= 90) return 14;
if (days <= 365) return 30;
return (days / 6).ceilToDouble();
}
String _shortDate(DateTime d) {
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
return "${months[d.month - 1]} ${d.day}";
}

View file

@ -0,0 +1,258 @@
import 'dart:math';
import 'package:capstone_project/models/event_signal.dart';
import 'package:capstone_project/services/stock_price_service.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
const Color _growthColor = Color(0xFF16a34a);
const Color _declineColor = Color(0xFFdc2626);
class StockPriceChart extends StatelessWidget {
final List<StockPricePoint> prices;
final List<EventSignal> signals;
final double height;
final bool compact;
const StockPriceChart({
super.key,
required this.prices,
this.signals = const [],
this.height = 220,
this.compact = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (prices.isEmpty) {
return Container(
width: double.infinity,
height: height,
decoration: BoxDecoration(
color: theme.colorScheme.secondary,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: theme.colorScheme.border, width: 1),
),
alignment: Alignment.center,
child: Text('No price history available').muted.small,
);
}
final sortedPrices = [...prices]..sort((a, b) => a.date.compareTo(b.date));
final spots = List.generate(
sortedPrices.length,
(i) => FlSpot(i.toDouble(), sortedPrices[i].close),
);
final minPrice = sortedPrices.map((p) => p.close).reduce(min);
final maxPrice = sortedPrices.map((p) => p.close).reduce(max);
final pad = max((maxPrice - minPrice) * 0.15, maxPrice * 0.02);
final lineColor = sortedPrices.last.close >= sortedPrices.first.close ? _growthColor : _declineColor;
final labelStyle = GoogleFonts.ibmPlexMono(
fontSize: 10,
height: 1,
fontWeight: FontWeight.w500,
color: theme.colorScheme.mutedForeground,
);
return SizedBox(
width: double.infinity,
height: height,
child: LineChart(
LineChartData(
minY: minPrice - pad,
maxY: maxPrice + pad,
minX: 0,
maxX: spots.length > 1 ? (spots.length - 1).toDouble() : 1,
gridData: FlGridData(
show: !compact,
drawVerticalLine: false,
horizontalInterval: _priceInterval(minPrice, maxPrice),
getDrawingHorizontalLine: (_) => FlLine(
color: theme.colorScheme.border,
strokeWidth: 1,
),
),
borderData: FlBorderData(
show: !compact,
border: Border.all(color: theme.colorScheme.border, width: 1),
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: !compact,
reservedSize: 22,
interval: _xInterval(sortedPrices.length),
getTitlesWidget: (value, meta) {
final index = value.round();
if (index < 0 || index >= sortedPrices.length) return const SizedBox.shrink();
if ((value - index).abs() > 0.01) return const SizedBox.shrink();
final d = sortedPrices[index].date;
return Padding(
padding: const EdgeInsets.only(top: 4),
child: RichText(text: TextSpan(text: _shortDate(d), style: labelStyle)),
);
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: !compact,
reservedSize: 52,
interval: _priceInterval(minPrice, maxPrice),
getTitlesWidget: (value, meta) => RichText(
text: TextSpan(text: '\$${value.toStringAsFixed(0)}', style: labelStyle),
),
),
),
),
lineTouchData: compact
? LineTouchData(enabled: false)
: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipColor: (_) => theme.colorScheme.background,
tooltipBorder: BorderSide(color: theme.colorScheme.border, width: 1),
tooltipPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
getTooltipItems: (touchedSpots) {
return touchedSpots.map((spot) {
final index = spot.x.round();
if (index < 0 || index >= sortedPrices.length) return null;
final d = sortedPrices[index].date;
return LineTooltipItem(
'${_shortDate(d)}\n\$${spot.y.toStringAsFixed(2)}',
GoogleFonts.ibmPlexMono(
fontSize: 11,
fontWeight: FontWeight.w600,
color: theme.colorScheme.foreground,
),
);
}).toList();
},
),
),
extraLinesData: ExtraLinesData(
extraLinesOnTop: true,
horizontalLines: [],
verticalLines: compact ? [] : _buildSignalLines(sortedPrices, signals, theme),
),
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: true,
color: lineColor,
barWidth: compact ? 2.5 : 2,
belowBarData: compact
? BarAreaData(show: false)
: BarAreaData(show: true, color: lineColor.withValues(alpha: 0.10)),
dotData: FlDotData(
show: !compact,
getDotPainter: (spot, percent, barData, index) {
final matchingSignal = _nearestSignal(sortedPrices[index].date, signals);
if (matchingSignal == null) {
return FlDotCirclePainter(radius: 0, color: Colors.transparent);
}
return FlDotCirclePainter(
radius: 4,
color: _signalColor(matchingSignal.direction),
strokeWidth: 1.5,
strokeColor: theme.colorScheme.background,
);
},
),
),
],
),
),
);
}
List<VerticalLine> _buildSignalLines(
List<StockPricePoint> prices,
List<EventSignal> signals,
dynamic theme,
) {
return signals
.map((signal) {
final idx = _nearestPriceIndex(prices, signal.createdAt);
if (idx == null) return null;
return VerticalLine(
x: idx.toDouble(),
color: _signalColor(signal.direction).withValues(alpha: 0.4),
strokeWidth: 1,
dashArray: [4, 4],
);
})
.whereType<VerticalLine>()
.toList();
}
EventSignal? _nearestSignal(DateTime priceDate, List<EventSignal> signals) {
EventSignal? nearest;
Duration? smallest;
for (final s in signals) {
final diff = s.createdAt.difference(priceDate).abs();
if (smallest == null || diff < smallest) {
smallest = diff;
nearest = s;
}
}
return (smallest != null && smallest <= const Duration(days: 2)) ? nearest : null;
}
int? _nearestPriceIndex(List<StockPricePoint> prices, DateTime target) {
if (prices.isEmpty) return null;
int idx = 0;
Duration nearest = prices.first.date.difference(target).abs();
for (int i = 1; i < prices.length; i++) {
final diff = prices[i].date.difference(target).abs();
if (diff < nearest) {
nearest = diff;
idx = i;
}
}
return nearest <= const Duration(days: 7) ? idx : null;
}
double _priceInterval(double min, double max) {
final range = max - min;
if (range <= 1) return 0.25;
if (range <= 5) return 1;
if (range <= 20) return 5;
if (range <= 100) return 20;
return range / 4;
}
double _xInterval(int length) {
if (length <= 7) return 1;
if (length <= 14) return 3;
return (length / 4).ceilToDouble();
}
Color _signalColor(String direction) => switch (direction) {
'positive' => _growthColor,
'negative' => _declineColor,
_ => const Color(0xFF888888),
};
}
String _shortDate(DateTime d) {
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
return '${months[d.month - 1]} ${d.day}';
}

View file

@ -6,14 +6,10 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <irondash_engine_context/irondash_engine_context_plugin.h> #include <url_launcher_linux/url_launcher_plugin.h>
#include <super_native_extensions/super_native_extensions_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) irondash_engine_context_registrar = g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "IrondashEngineContextPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
irondash_engine_context_plugin_register_with_registrar(irondash_engine_context_registrar); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
g_autoptr(FlPluginRegistrar) super_native_extensions_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "SuperNativeExtensionsPlugin");
super_native_extensions_plugin_register_with_registrar(super_native_extensions_registrar);
} }

View file

@ -3,8 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
irondash_engine_context url_launcher_linux
super_native_extensions
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST

View file

@ -5,18 +5,14 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import device_info_plus
import file_picker import file_picker
import irondash_engine_context
import path_provider_foundation import path_provider_foundation
import shared_preferences_foundation import shared_preferences_foundation
import super_native_extensions import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
} }

View file

@ -1,6 +1,5 @@
platform :osx, '10.15' platform :osx, '10.15'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true' ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', { project 'Runner', {

16
macos/Podfile.lock Normal file
View file

@ -0,0 +1,16 @@
PODS:
- FlutterMacOS (1.0.0)
DEPENDENCIES:
- FlutterMacOS (from `Flutter/ephemeral`)
EXTERNAL SOURCES:
FlutterMacOS:
:path: Flutter/ephemeral
SPEC CHECKSUMS:
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
PODFILE CHECKSUM: 0792f0b89dc4a5c984ee1474972d534d41da08a2
COCOAPODS: 1.16.2

View file

@ -27,6 +27,7 @@
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
93FF9985C779BC8BA2DCAFDE /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 21B909AE7524BBE81DBDF09D /* Pods_Runner.framework */; }; 93FF9985C779BC8BA2DCAFDE /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 21B909AE7524BBE81DBDF09D /* Pods_Runner.framework */; };
98FD69AA4561E2418368B2C0 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1B93A602E978951E58D988DB /* Pods_RunnerTests.framework */; }; 98FD69AA4561E2418368B2C0 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1B93A602E978951E58D988DB /* Pods_RunnerTests.framework */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@ -84,6 +85,7 @@
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
7735CDB69586530F7D7B35C7 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; }; 7735CDB69586530F7D7B35C7 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
7F4CC95F0095086E4A6A44FE /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; }; 7F4CC95F0095086E4A6A44FE /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
@ -103,6 +105,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
93FF9985C779BC8BA2DCAFDE /* Pods_Runner.framework in Frameworks */, 93FF9985C779BC8BA2DCAFDE /* Pods_Runner.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -164,6 +167,7 @@
33CEB47122A05771004F2AC0 /* Flutter */ = { 33CEB47122A05771004F2AC0 /* Flutter */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
@ -240,7 +244,6 @@
33CC10EB2044A3C60003C045 /* Resources */, 33CC10EB2044A3C60003C045 /* Resources */,
33CC110E2044A8840003C045 /* Bundle Framework */, 33CC110E2044A8840003C045 /* Bundle Framework */,
3399D490228B24CF009A79C7 /* ShellScript */, 3399D490228B24CF009A79C7 /* ShellScript */,
67D0769C6B58A1F69B052AF5 /* [CP] Embed Pods Frameworks */,
); );
buildRules = ( buildRules = (
); );
@ -248,6 +251,9 @@
33CC11202044C79F0003C045 /* PBXTargetDependency */, 33CC11202044C79F0003C045 /* PBXTargetDependency */,
); );
name = Runner; name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner; productName = Runner;
productReference = 33CC10ED2044A3C60003C045 /* capstone_project.app */; productReference = 33CC10ED2044A3C60003C045 /* capstone_project.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
@ -292,6 +298,9 @@
Base, Base,
); );
mainGroup = 33CC10E42044A3C60003C045; mainGroup = 33CC10E42044A3C60003C045;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
projectDirPath = ""; projectDirPath = "";
projectRoot = ""; projectRoot = "";
@ -383,23 +392,6 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
}; };
67D0769C6B58A1F69B052AF5 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
809566E35A7164B0BF441F0A /* [CP] Check Pods Manifest.lock */ = { 809566E35A7164B0BF441F0A /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@ -796,6 +788,20 @@
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
}; };
rootObject = 33CC10E52044A3C60003C045 /* Project object */; rootObject = 33CC10E52044A3C60003C045 /* Project object */;
} }

View file

@ -5,6 +5,24 @@
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"> buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "&quot;$FLUTTER_ROOT&quot;/packages/flutter_tools/bin/macos_assemble.sh prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "capstone_project.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries> <BuildActionEntries>
<BuildActionEntry <BuildActionEntry
buildForTesting = "YES" buildForTesting = "YES"

97
mvp_watchlist.md Normal file
View file

@ -0,0 +1,97 @@
# Augor — Watchlist MVP
## Goal
Replace the current bulk-feed home screen with a stock-watchlist-centric UX. Users add stocks they care about; the app fetches relevant news, runs LLM analysis, and surfaces per-stock growth signals with a confidence score.
## Scope (in)
- Watchlist: add/remove stock tickers (e.g. AAPL, TSLA)
- Home screen: grid of stock cards showing ticker, last signal (growth/decline/neutral), probability, and a sparkline placeholder
- Stock dashboard: full signal list for that stock, rationale text, source articles
- Per-stock pipeline: filter aggregated feed by ticker relevance, run clustering + signal generation scoped to that ticker
- Settings: API key, feed list (unchanged), watchlist persisted via SharedPreferences
## Scope (out — do later)
- Real-time or live price data
- Actual candlestick/OHLC charts (use placeholder or flat line for now)
- Backtesting
- Push notifications
- Backend / cloud processing
## Data Model
```dart
class WatchedStock {
final String ticker; // e.g. "AAPL"
final String companyName; // e.g. "Apple Inc."
EventSignal? latestSignal;
List<EventSignal> signalHistory;
}
```
`WatchedStock` is persisted as JSON in SharedPreferences under key `watched_stocks`.
## State
Add a `WatchlistProvider` (ChangeNotifier):
- `List<WatchedStock> stocks`
- `addStock(ticker, companyName)`
- `removeStock(ticker)`
- `updateSignals(ticker, List<EventSignal>)`
- `save()` / `load()`
## Routing (go_router)
| Path | Page |
|---|---|
| `/` | Home — stock card grid |
| `/stock/:ticker` | Stock dashboard |
| `/settings` | Settings (existing) |
| `/watchlist/add` | Add stock dialog or page |
## Home Screen
- `GridView` of `StockCard` widgets, one per watched stock
- Each card: ticker symbol (large), company name, signal badge (colored chip), probability percentage, placeholder mini-chart area
- Floating action button or top-right icon to add a stock
- Empty state: prompt to add a stock
## Stock Dashboard
- Header: ticker + company name
- "Run Analysis" button — triggers the per-stock pipeline
- Status/progress text
- Placeholder chart (Container with grey background for now)
- Scrollable list of `EventSignalCard` widgets (reuse existing widget)
- Each card shows summary, signal, probability, confidence, rationale, source count
## Per-Stock Pipeline
Scoped version of the existing pipeline:
1. Fetch all enabled feeds (same as now)
2. Filter `FeedItem` list to items relevant to the ticker — use company name + ticker as keywords alongside existing keyword list
3. Generate embeddings on filtered items only
4. Group by event (existing clustering)
5. Generate signals (existing `SignalGenerator`)
6. Store results in `WatchlistProvider.updateSignals(ticker, signals)`
The simplest first pass: add ticker + company name into the relevance keyword check. No separate embedding per ticker yet — just string matching on title/description.
## Build Order
1. `WatchedStock` model + `WatchlistProvider`
2. Persist watchlist in SharedPreferences
3. New home screen with `StockCard` grid and add-stock flow
4. `go_router` routes updated
5. Stock dashboard page (static layout first)
6. Wire per-stock pipeline to dashboard "Run Analysis" button
7. Verbose progress logging (status string passed down to UI)
8. Placeholder chart widget
## Definition of Done
- User can add AAPL and TSLA to watchlist
- Home screen shows both cards
- Tapping a card opens its dashboard
- Tapping "Run Analysis" runs the pipeline and populates signal cards
- At least one signal card renders with summary + probability
- No crashes on empty watchlist or missing API key

59
progress.md Normal file
View file

@ -0,0 +1,59 @@
# Progress
Last updated: 2026-03-06
## Current Status
The project is in a functional prototype stage: core feed aggregation and embedding-based grouping are implemented, but production hardening and test quality are still pending.
## Completed
- Flutter app shell with desktop platform support (macOS, Linux, Windows).
- Navigation between Home and Settings pages (`go_router`).
- Persistent settings management via `Provider` + `SharedPreferences`.
- Default catalog of business/finance feeds.
- Feed parsing for both RSS and Atom formats.
- Concurrent feed retrieval and aggregation.
- OpenRouter embeddings integration (`openai/text-embedding-3-small`).
- Relevance filtering using keyword embedding similarity.
- Event grouping by cosine similarity.
- JSON output export pipeline for raw/enriched/relevant/grouped datasets.
## In Progress / Partial
- UX for long-running pipeline execution:
- Processing runs on button tap with console logs only.
- No progress indicator, cancellation, or error surface in UI.
- Data quality controls:
- No deduplication or normalization layer before grouping.
- No source weighting/confidence scoring.
- Architecture maturation:
- Core pipeline is orchestrated in UI layer rather than dedicated service/use-case layer.
## Known Defects and Risks
- `SettingsPage` can crash when API key is empty:
- `apiKey.substring(0, 8)` is called even when key is `""`.
- Disabled feeds are still processed:
- Home page currently sends all feed URLs, not only `enabled == true`.
- `updateFeed` replaces feed object and can change derived `id`:
- If title/url changes, later operations using old id may become inconsistent.
- No guardrails for invalid/empty API key before embedding requests.
- Error handling is mostly `print`-based and non-user-visible.
## Validation Snapshot (run on 2026-03-06)
- `flutter analyze`: fails with 24 issues
- Includes 2 warnings (immutability, unused import) and multiple lint infos.
- `flutter test`: fails
- Existing test is the default counter smoke test and does not match current UI.
## Testing Coverage
- No meaningful unit tests for:
- feed parsing
- similarity calculations
- grouping behavior
- settings persistence
- No widget/integration tests for Home and Settings user flows.
## Next High-Impact Steps
1. Fix the `SettingsPage` API key crash and respect feed `enabled` flags during aggregation.
2. Replace template widget test with app-specific widget tests for navigation + settings.
3. Extract pipeline orchestration from `HomePage` into a dedicated service for testability.
4. Add structured error/reporting model surfaced in the UI.
5. Add retry/backoff and timeout handling for external requests.

View file

@ -1,14 +1,14 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
archive: animation_kit:
dependency: transitive dependency: transitive
description: description:
name: archive name: animation_kit
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" sha256: d9b0944b3ee02fae3fedbc6cb04d9a9ea26ad1d29f3261e0b55443b1e0bfba63
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.7" version: "0.0.2"
args: args:
dependency: transitive dependency: transitive
description: description:
@ -37,10 +37,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.1"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@ -69,10 +69,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: country_flags name: country_flags
sha256: "78a7bf8aabd7ae1a90087f0c517471ac9ebfe07addc652692f58da0f0f833196" sha256: f022d18337f3861f1f4e319b936cb53920de9259f38cb09e169eace9942e2b79
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.3.0" version: "4.1.2"
cross_file: cross_file:
dependency: transitive dependency: transitive
description: description:
@ -85,10 +85,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: crypto name: crypto
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.6" version: "3.0.7"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@ -101,10 +101,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: data_widget name: data_widget
sha256: "95388df890189014f702b7e93f9de6bcf7d45143a99f6288f31899f10be441ba" sha256: "4947aae3c50635496d56f94ad18de98e19015c5ebf01abee0f39a2c098c7021a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.0.2" version: "0.0.3"
dbus: dbus:
dependency: transitive dependency: transitive
description: description:
@ -113,22 +113,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.11" version: "0.7.11"
device_info_plus:
dependency: transitive
description:
name: device_info_plus
sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a"
url: "https://pub.dev"
source: hosted
version: "11.5.0"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
url: "https://pub.dev"
source: hosted
version: "7.0.3"
email_validator: email_validator:
dependency: transitive dependency: transitive
description: description:
@ -137,6 +121,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" version: "3.0.0"
equatable:
dependency: transitive
description:
name: equatable
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
url: "https://pub.dev"
source: hosted
version: "2.0.8"
expressions: expressions:
dependency: transitive dependency: transitive
description: description:
@ -177,14 +169,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.3.7" version: "10.3.7"
fixnum: fl_chart:
dependency: transitive dependency: "direct main"
description: description:
name: fixnum name: fl_chart
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be sha256: "74959b99b92b9eebeed1a4049426fd67c4abc3c5a0f4d12e2877097d6a11ae08"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "0.69.2"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@ -198,6 +190,11 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" version: "6.0.0"
flutter_localizations:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_plugin_android_lifecycle: flutter_plugin_android_lifecycle:
dependency: transitive dependency: transitive
description: description:
@ -232,6 +229,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "16.2.4" version: "16.2.4"
google_fonts:
dependency: "direct main"
description:
name: google_fonts
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
url: "https://pub.dev"
source: hosted
version: "6.3.3"
http: http:
dependency: "direct main" dependency: "direct main"
description: description:
@ -248,30 +253,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image: intl:
dependency: transitive dependency: transitive
description: description:
name: image name: intl
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.5.4" version: "0.20.2"
irondash_engine_context:
dependency: transitive
description:
name: irondash_engine_context
sha256: "2bb0bc13dfda9f5aaef8dde06ecc5feb1379f5bb387d59716d799554f3f305d7"
url: "https://pub.dev"
source: hosted
version: "0.5.5"
irondash_message_channel:
dependency: transitive
description:
name: irondash_message_channel
sha256: b4101669776509c76133b8917ab8cfc704d3ad92a8c450b92934dd8884a2f060
url: "https://pub.dev"
source: hosted
version: "0.7.0"
jovial_misc: jovial_misc:
dependency: transitive dependency: transitive
description: description:
@ -332,26 +321,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.17" version: "0.12.19"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.11.1" version: "0.13.0"
meta: meta:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.18.0"
nested: nested:
dependency: transitive dependency: transitive
description: description:
@ -432,14 +421,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.0.4" version: "0.0.4"
pixel_snap:
dependency: transitive
description:
name: pixel_snap
sha256: "677410ea37b07cd37ecb6d5e6c0d8d7615a7cf3bd92ba406fd1ac57e937d1fb0"
url: "https://pub.dev"
source: hosted
version: "0.1.5"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@ -456,14 +437,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
posix:
dependency: transitive
description:
name: posix
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
url: "https://pub.dev"
source: hosted
version: "6.0.3"
provider: provider:
dependency: "direct main" dependency: "direct main"
description: description:
@ -492,10 +465,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: shadcn_flutter name: shadcn_flutter
sha256: af83de199b7c3a965ab24e293cfcafe2764c12b7f911f5b1a427c332029262d9 sha256: b04e2f790e182007d02b78234c647df393f2ea95b39d8da88d7cbdaed56f7701
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.0.44" version: "0.0.52"
shared_preferences: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
@ -573,14 +546,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.1" version: "1.10.1"
sprintf:
dependency: transitive
description:
name: sprintf
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
@ -605,30 +570,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
super_clipboard:
dependency: transitive
description:
name: super_clipboard
sha256: e73f3bb7e66cc9260efa1dc507f979138e7e106c3521e2dda2d0311f6d728a16
url: "https://pub.dev"
source: hosted
version: "0.9.1"
super_native_extensions:
dependency: transitive
description:
name: super_native_extensions
sha256: b9611dcb68f1047d6f3ef11af25e4e68a21b1a705bbcc3eb8cb4e9f5c3148569
url: "https://pub.dev"
source: hosted
version: "0.9.1"
syntax_highlight:
dependency: transitive
description:
name: syntax_highlight
sha256: "4d3ba40658cadba6ba55d697f29f00b43538ebb6eb4a0ca0e895c568eaced138"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:
@ -641,10 +582,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.7" version: "0.7.10"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@ -661,14 +602,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.1" version: "0.1.1"
uuid: url_launcher:
dependency: transitive dependency: "direct main"
description: description:
name: uuid name: url_launcher
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.5.1" version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
url: "https://pub.dev"
source: hosted
version: "6.3.29"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
url: "https://pub.dev"
source: hosted
version: "2.4.2"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@ -701,14 +698,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.14.0" version: "5.14.0"
win32_registry:
dependency: transitive
description:
name: win32_registry
sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:
@ -726,5 +715,5 @@ packages:
source: hosted source: hosted
version: "6.6.1" version: "6.6.1"
sdks: sdks:
dart: ">=3.10.0-75.1.beta <4.0.0" dart: ">=3.10.0 <4.0.0"
flutter: ">=3.35.1" flutter: ">=3.38.0"

View file

@ -31,7 +31,7 @@ dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
go_router: ^16.2.4 go_router: ^16.2.4
shadcn_flutter: ^0.0.44 shadcn_flutter: ^0.0.52
provider: ^6.1.5+1 provider: ^6.1.5+1
shared_preferences: ^2.5.4 shared_preferences: ^2.5.4
uid: ^0.1.1 uid: ^0.1.1
@ -39,6 +39,9 @@ dependencies:
file_picker: ^10.3.7 file_picker: ^10.3.7
path_provider: ^2.1.5 path_provider: ^2.1.5
http: ^1.2.2 http: ^1.2.2
fl_chart: ^0.69.0
google_fonts: ^6.2.1
url_launcher: ^6.3.1
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.

View file

@ -1,30 +1,22 @@
// This is a basic Flutter widget test. import 'package:capstone_project/pages/home.dart';
// import 'package:capstone_project/providers/watchlist.dart';
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:capstone_project/main.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
void main() { void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async { testWidgets('home page shows empty watchlist state and add action', (WidgetTester tester) async {
// Build our app and trigger a frame. await tester.pumpWidget(
await tester.pumpWidget(const MyApp()); ChangeNotifierProvider(
create: (_) => WatchlistProvider(),
child: const ShadcnApp(
home: HomePage(),
),
),
);
// Verify that our counter starts at 0. expect(find.text('Augor'), findsOneWidget);
expect(find.text('0'), findsOneWidget); expect(find.text('Your watchlist is empty'), findsOneWidget);
expect(find.text('1'), findsNothing); expect(find.text('Add stock'), findsOneWidget);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
}); });
} }

View file

@ -6,12 +6,9 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <irondash_engine_context/irondash_engine_context_plugin_c_api.h> #include <url_launcher_windows/url_launcher_windows.h>
#include <super_native_extensions/super_native_extensions_plugin_c_api.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
IrondashEngineContextPluginCApiRegisterWithRegistrar( UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("IrondashEngineContextPluginCApi")); registry->GetRegistrarForPlugin("UrlLauncherWindows"));
SuperNativeExtensionsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SuperNativeExtensionsPluginCApi"));
} }

View file

@ -3,8 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
irondash_engine_context url_launcher_windows
super_native_extensions
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST