Refactor project structure and enhance stock watchlist functionality
This commit is contained in:
parent
491bf2bbea
commit
ed617b7498
41 changed files with 6566 additions and 1289 deletions
2470
.the_agency/sessions/4c4b2813-cf8a-4272-a8a1-1294532ce6f4.json
Normal file
2470
.the_agency/sessions/4c4b2813-cf8a-4272-a8a1-1294532ce6f4.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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"
|
||||
}
|
||||
10
README.md
10
README.md
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
## 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
|
||||
|
||||
|
|
@ -15,13 +15,13 @@ Augor is a Flutter desktop application that aggregates financial news from multi
|
|||
- Enable/disable feeds individually
|
||||
|
||||
**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
|
||||
- Groups related articles about the same event using cosine similarity
|
||||
- Exports results as JSON for further analisis
|
||||
|
||||
**Settings Management**
|
||||
- Configure OpenAI API key
|
||||
- Configure OpenRouter API key
|
||||
- Manage RSS feed sources
|
||||
- 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)
|
||||
- **shadcn_flutter** - UI component library
|
||||
- **OpenAI API** - Text embeddings for semantic similarity
|
||||
- **OpenRouter API** - Embeddings and LLM inference
|
||||
- **Provider** - State managment
|
||||
- **go_router** - Navigation
|
||||
|
||||
|
|
@ -40,7 +40,7 @@ Augor is a Flutter desktop application that aggregates financial news from multi
|
|||
```bash
|
||||
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:
|
||||
```bash
|
||||
flutter run
|
||||
|
|
|
|||
123
architecture.md
Normal file
123
architecture.md
Normal 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
123
lib/data/stock_list.dart
Normal 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.'}
|
||||
];
|
||||
|
|
@ -1,16 +1,20 @@
|
|||
import 'package:capstone_project/pages/home.dart';
|
||||
import 'package:capstone_project/pages/settings.dart';
|
||||
import 'package:capstone_project/pages/stock_dashboard.dart';
|
||||
import 'package:capstone_project/providers/settings.dart';
|
||||
import 'package:capstone_project/providers/watchlist.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
SettingsProvider _settingsProvider = SettingsProvider();
|
||||
WatchlistProvider _watchlistProvider = WatchlistProvider();
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
await _settingsProvider.load();
|
||||
await _watchlistProvider.load();
|
||||
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
|
@ -24,10 +28,14 @@ class MyApp extends StatelessWidget {
|
|||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider.value(value: _settingsProvider),
|
||||
ChangeNotifierProvider.value(value: _watchlistProvider),
|
||||
],
|
||||
child: ShadcnApp.router(
|
||||
scaling: const AdaptiveScaling(0.9),
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorSchemes.darkRose
|
||||
colorScheme: ColorSchemes.darkSlate,
|
||||
density: Density.spaciousDensity,
|
||||
radius: 0.5,
|
||||
),
|
||||
routerConfig: _routerConfig,
|
||||
),
|
||||
|
|
@ -39,5 +47,6 @@ GoRouter _routerConfig = GoRouter(
|
|||
routes: [
|
||||
HomePage.route,
|
||||
SettingsPage.route,
|
||||
StockDashboardPage.route,
|
||||
]
|
||||
);
|
||||
);
|
||||
|
|
|
|||
120
lib/models/event_signal.dart
Normal file
120
lib/models/event_signal.dart
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import "package:capstone_project/utils/agrigator.dart";
|
||||
|
||||
|
||||
// raised when deserializing a signal payload that predates the direction/impact
|
||||
// rework. the loader catches this and skips the signal.
|
||||
class LegacySignalException implements Exception {
|
||||
final String message;
|
||||
LegacySignalException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => "LegacySignalException: $message";
|
||||
}
|
||||
|
||||
|
||||
class EventSignal {
|
||||
final String eventId;
|
||||
final String eventSummary;
|
||||
|
||||
// positive | negative | neutral
|
||||
final String direction;
|
||||
|
||||
// forecasting | reactive
|
||||
final String nature;
|
||||
|
||||
// likelihood the event is real / actually happened as reported (0..1)
|
||||
final double probability;
|
||||
|
||||
// magnitude of expected immediate price reaction, assuming the event is real (0..1)
|
||||
final double impact;
|
||||
|
||||
final String rationale;
|
||||
final List<FeedItem> articles;
|
||||
final DateTime createdAt;
|
||||
|
||||
EventSignal({
|
||||
required this.eventId,
|
||||
required this.eventSummary,
|
||||
required this.direction,
|
||||
this.nature = "reactive",
|
||||
required this.probability,
|
||||
required this.impact,
|
||||
required this.rationale,
|
||||
required this.articles,
|
||||
DateTime? createdAt,
|
||||
}) : createdAt = createdAt ?? DateTime.now();
|
||||
|
||||
factory EventSignal.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
List<FeedItem> articles, {
|
||||
String? eventIdOverride,
|
||||
}) {
|
||||
// legacy schema discriminator — old signals had `signal` + `confidence`
|
||||
// and no `impact`/`direction`. those numbers meant something different
|
||||
// (or nothing) so we refuse to load them instead of silently remapping.
|
||||
final hasImpact = json.containsKey("impact");
|
||||
final hasDirection = json.containsKey("direction");
|
||||
|
||||
if (!hasImpact || !hasDirection) {
|
||||
throw LegacySignalException(
|
||||
"payload missing impact/direction — incompatable with current schema",
|
||||
);
|
||||
}
|
||||
|
||||
return EventSignal(
|
||||
eventId: eventIdOverride ?? (json["event_id"] ?? "").toString(),
|
||||
eventSummary: (json["event_summary"] ?? "").toString(),
|
||||
direction: _normalizeDirection((json["direction"] ?? "").toString()),
|
||||
nature: _normalizeNature((json["nature"] ?? "").toString()),
|
||||
probability: _normalizeScore(json["probability"]),
|
||||
impact: _normalizeScore(json["impact"]),
|
||||
rationale: (json["rationale"] ?? "").toString(),
|
||||
articles: articles,
|
||||
createdAt: DateTime.tryParse((json["created_at"] ?? "").toString()),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
"event_id": eventId,
|
||||
"event_summary": eventSummary,
|
||||
"direction": direction,
|
||||
"nature": nature,
|
||||
"probability": probability,
|
||||
"impact": impact,
|
||||
"rationale": rationale,
|
||||
"articles": articles.map((article) => article.toJson()).toList(),
|
||||
"created_at": createdAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
static String _normalizeNature(String value) {
|
||||
return value.trim().toLowerCase() == "forecasting" ? "forecasting" : "reactive";
|
||||
}
|
||||
|
||||
static String _normalizeDirection(String value) {
|
||||
switch (value.trim().toLowerCase()) {
|
||||
case "positive":
|
||||
return "positive";
|
||||
case "negative":
|
||||
return "negative";
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
}
|
||||
|
||||
static double _normalizeScore(dynamic value) {
|
||||
if (value is num) {
|
||||
return value.toDouble().clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
if (value is String) {
|
||||
final parsed = double.tryParse(value);
|
||||
if (parsed != null) {
|
||||
return parsed.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
61
lib/models/watched_stock.dart
Normal file
61
lib/models/watched_stock.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,136 +1,359 @@
|
|||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:capstone_project/providers/settings.dart';
|
||||
import 'package:capstone_project/utils/agrigator.dart';
|
||||
import 'package:capstone_project/widgets/navbar.dart';
|
||||
import 'package:capstone_project/data/stock_list.dart';
|
||||
import 'package:capstone_project/models/watched_stock.dart';
|
||||
import 'package:capstone_project/providers/watchlist.dart';
|
||||
import 'package:capstone_project/services/stock_price_service.dart';
|
||||
import 'package:capstone_project/widgets/app_shell.dart';
|
||||
import 'package:capstone_project/widgets/stock_price_chart.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
|
||||
class HomePage extends StatelessWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
static GoRoute route = GoRoute(
|
||||
path: "/",
|
||||
builder: (context, state) => HomePage()
|
||||
builder: (context, state) => const HomePage(),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// TODO: implement build
|
||||
return Scaffold(
|
||||
headers: [
|
||||
AppBar()
|
||||
],
|
||||
footers: [
|
||||
ProjNavBar(
|
||||
currentPage: "home",
|
||||
)
|
||||
],
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
return Consumer<WatchlistProvider>(
|
||||
builder: (context, watchlist, _) {
|
||||
final stocks = watchlist.stocks;
|
||||
|
||||
Text(
|
||||
"Nothing here is final yet!",
|
||||
).h1,
|
||||
|
||||
Gap(16),
|
||||
|
||||
Button.primary(
|
||||
onPressed: () async {
|
||||
print("Aggregating feeds...");
|
||||
SettingsProvider settings = SettingsProvider.of(context);
|
||||
|
||||
// Fetch all feeds
|
||||
List<Uri> feedUris = settings.feeds.map((feed) => Uri.parse(feed.url)).toList();
|
||||
List<FeedItem> aggregatedItems = await fetchFeeds(feedUris);
|
||||
|
||||
// Save it to a file before generating embeddings
|
||||
String agregatedJson = JsonEncoder.withIndent(' ').convert(aggregatedItems);
|
||||
File agregatedJsonFile = File("${settings.applicationStorageLocation}/aggregated_feed.json");
|
||||
await agregatedJsonFile.writeAsString(agregatedJson);
|
||||
print("Aggregated feed saved to ${agregatedJsonFile.path}");
|
||||
|
||||
// Generate embeddings for all items
|
||||
print("Generating embeddings for ${aggregatedItems.length} items...");
|
||||
List<FeedItem> enrichedItems = [...aggregatedItems];
|
||||
await generateEmbeddings(enrichedItems, settings.openAIApiKey);
|
||||
|
||||
// Save it to a file in the application storage location
|
||||
String enrichedJson = JsonEncoder.withIndent(' ').convert(enrichedItems);
|
||||
final file = File("${settings.applicationStorageLocation}/enriched_aggregated_feed.json");
|
||||
await file.writeAsString(enrichedJson);
|
||||
print("Enriched aggregated feed saved to ${file.path}");
|
||||
|
||||
// Filter out irrelevant items
|
||||
print("Filtering relevant items...");
|
||||
await generateKeywordEmbeddings(settings.openAIApiKey);
|
||||
List<FeedItem> relevantItems = [...aggregatedItems]..removeWhere((item) => !isFeedItemRelevant(item));
|
||||
String relevantJson = JsonEncoder.withIndent(' ').convert(relevantItems);
|
||||
final fileRelevant = File("${settings.applicationStorageLocation}/relevant_aggregated_feed.json");
|
||||
await fileRelevant.writeAsString(relevantJson);
|
||||
print("Cut down from ${aggregatedItems.length} to ${relevantItems.length} relevant items.");
|
||||
print("Relevant aggregated feed saved to ${fileRelevant.path}");
|
||||
|
||||
// For human readability, save a version without embeddings
|
||||
List<FeedItem> readableItems = relevantItems.map((item) {
|
||||
return FeedItem(
|
||||
title: item.title,
|
||||
link: item.link,
|
||||
description: item.description,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
String readableJson = JsonEncoder.withIndent(' ').convert(readableItems);
|
||||
final fileReadable = File("${settings.applicationStorageLocation}/readable_relevant_aggregated_feed.json");
|
||||
await fileReadable.writeAsString(readableJson);
|
||||
print("Readable relevant aggregated feed saved to ${fileReadable.path}");
|
||||
|
||||
// Group by event
|
||||
print("Grouping feed items by event...");
|
||||
List<List<FeedItem>> groupedItems = groupFeedItemsByEvent(relevantItems);
|
||||
List<List<Map<String, dynamic>>> groupedItemsJson = groupedItems.map((group) {
|
||||
return group.map((item) => item.toJson()).toList();
|
||||
}).toList();
|
||||
String groupedJson = JsonEncoder.withIndent(' ').convert(groupedItemsJson);
|
||||
final fileGrouped = File("${settings.applicationStorageLocation}/grouped_relevant_aggregated_feed.json");
|
||||
await fileGrouped.writeAsString(groupedJson);
|
||||
print("Grouped relevant aggregated feed saved to ${fileGrouped.path}");
|
||||
|
||||
// For human readability, save a version without embeddings
|
||||
List<List<FeedItem>> readableGroupedItems = groupedItems.map((group) {
|
||||
return group.map((item) {
|
||||
return FeedItem(
|
||||
title: item.title,
|
||||
link: item.link,
|
||||
description: item.description,
|
||||
);
|
||||
}).toList();
|
||||
}).toList();
|
||||
// Sort groups by size descending
|
||||
readableGroupedItems.sort((a, b) => b.length.compareTo(a.length));
|
||||
List<List<Map<String, dynamic>>> readableGroupedItemsJson = readableGroupedItems.map((group) {
|
||||
return group.map((item) => item.toJson()).toList();
|
||||
}).toList();
|
||||
String readableGroupedJson = JsonEncoder.withIndent(' ').convert(readableGroupedItemsJson);
|
||||
final fileReadableGrouped = File("${settings.applicationStorageLocation}/readable_grouped_relevant_aggregated_feed.json");
|
||||
await fileReadableGrouped.writeAsString(readableGroupedJson);
|
||||
print("Readable grouped relevant aggregated feed saved to ${fileReadableGrouped.path}");
|
||||
|
||||
},
|
||||
child: Text(
|
||||
"Aggregate your feeds"
|
||||
return AugorShell(
|
||||
titleTag: 'Watchlist',
|
||||
headerLeading: [
|
||||
_AppName(),
|
||||
],
|
||||
headerTrailing: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Button.primary(
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (context) => const AddStockDialog(),
|
||||
),
|
||||
child: const Text('Add stock'),
|
||||
),
|
||||
)
|
||||
),
|
||||
],
|
||||
statusLeft: StatusText('${stocks.length} stock${stocks.length == 1 ? '' : 's'} tracked'),
|
||||
child: stocks.isEmpty
|
||||
? Center(
|
||||
child: SizedBox(
|
||||
width: 520,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('Your watchlist is empty').h2,
|
||||
const Gap(8),
|
||||
Text('Add a stock to start tracking signals and news.').muted,
|
||||
const Gap(16),
|
||||
Button.primary(
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (context) => const AddStockDialog(),
|
||||
),
|
||||
child: const Text('Add your first stock'),
|
||||
),
|
||||
],
|
||||
).withPadding(all: 16),
|
||||
),
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 1100,
|
||||
child: Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
children: [
|
||||
for (final stock in stocks)
|
||||
SizedBox(
|
||||
width: 340,
|
||||
child: StockCard(stock: stock),
|
||||
),
|
||||
],
|
||||
).withPadding(all: 20),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _AppName extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return RichText(
|
||||
text: TextSpan(
|
||||
text: 'Augor',
|
||||
style: GoogleFonts.ibmPlexMono(
|
||||
fontSize: 13,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: theme.colorScheme.foreground,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class StockCard extends StatefulWidget {
|
||||
final WatchedStock stock;
|
||||
|
||||
const StockCard({super.key, required this.stock});
|
||||
|
||||
@override
|
||||
State<StockCard> createState() => _StockCardState();
|
||||
}
|
||||
|
||||
class _StockCardState extends State<StockCard> {
|
||||
static final StockPriceService _priceService = StockPriceService();
|
||||
|
||||
List<StockPricePoint> _prices = [];
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPrices();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant StockCard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.stock.ticker != widget.stock.ticker) {
|
||||
_loadPrices();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadPrices() async {
|
||||
try {
|
||||
final prices = await _priceService.fetchPriceHistory(widget.stock.ticker);
|
||||
if (!mounted) return;
|
||||
setState(() => _prices = prices);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _prices = []);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final stock = widget.stock;
|
||||
final signal = stock.latestSignal;
|
||||
|
||||
final probability = signal == null
|
||||
? 'No analysis yet'
|
||||
: '${(signal.impact * 100).toStringAsFixed(1)}% impact';
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () => GoRouter.of(context).go('/stock/${stock.ticker}'),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 80),
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered
|
||||
? theme.colorScheme.background
|
||||
: theme.colorScheme.background.withValues(alpha: 0.85),
|
||||
border: Border.all(color: theme.colorScheme.border, width: 1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: _hovered ? 0.2 : 0.12),
|
||||
blurRadius: 4,
|
||||
spreadRadius: _hovered ? 3 : 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(stock.ticker).h2,
|
||||
const Gap(4),
|
||||
Text(stock.companyName).muted,
|
||||
],
|
||||
),
|
||||
),
|
||||
_SignalBadge(direction: signal?.direction ?? 'neutral'),
|
||||
],
|
||||
),
|
||||
|
||||
const Gap(16),
|
||||
|
||||
Text(probability).semiBold,
|
||||
|
||||
const Gap(12),
|
||||
|
||||
StockPriceChart(
|
||||
prices: _prices,
|
||||
signals: stock.signalHistory,
|
||||
height: 80,
|
||||
compact: true,
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _SignalBadge extends StatelessWidget {
|
||||
final String direction;
|
||||
|
||||
const _SignalBadge({required this.direction});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final color = switch (direction) {
|
||||
'positive' => const Color(0xFF16a34a),
|
||||
'negative' => const Color(0xFFdc2626),
|
||||
_ => theme.colorScheme.mutedForeground,
|
||||
};
|
||||
|
||||
final bg = switch (direction) {
|
||||
'positive' => const Color(0xFF16a34a).withValues(alpha: 0.15),
|
||||
'negative' => const Color(0xFFdc2626).withValues(alpha: 0.15),
|
||||
_ => theme.colorScheme.border.withValues(alpha: 0.5),
|
||||
};
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: color.withValues(alpha: 0.3), width: 1),
|
||||
),
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
text: direction,
|
||||
style: GoogleFonts.ibmPlexMono(
|
||||
fontSize: 10,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AddStockDialog extends StatefulWidget {
|
||||
const AddStockDialog({super.key});
|
||||
|
||||
@override
|
||||
State<AddStockDialog> createState() => _AddStockDialogState();
|
||||
}
|
||||
|
||||
class _AddStockDialogState extends State<AddStockDialog> {
|
||||
String _query = '';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final normalizedQuery = _query.trim().toLowerCase();
|
||||
final results = stockList.where((stock) {
|
||||
if (normalizedQuery.isEmpty) return true;
|
||||
final ticker = (stock['ticker'] ?? '').toLowerCase();
|
||||
final name = (stock['name'] ?? '').toLowerCase();
|
||||
return ticker.contains(normalizedQuery) || name.contains(normalizedQuery);
|
||||
}).toList();
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text('Add stock'),
|
||||
content: SizedBox(
|
||||
width: 420,
|
||||
height: 420,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
placeholder: const Text('Search ticker or company'),
|
||||
onChanged: (value) => setState(() => _query = value),
|
||||
),
|
||||
const Gap(16),
|
||||
Expanded(
|
||||
child: results.isEmpty
|
||||
? Center(child: Text('No matching stocks found.').muted)
|
||||
: ListView.separated(
|
||||
itemCount: results.length,
|
||||
separatorBuilder: (context, index) => const Divider(),
|
||||
itemBuilder: (context, index) {
|
||||
final stock = results[index];
|
||||
final ticker = stock['ticker'] ?? '';
|
||||
final name = stock['name'] ?? '';
|
||||
final alreadyAdded = context
|
||||
.watch<WatchlistProvider>()
|
||||
.stocks
|
||||
.any((w) => w.ticker == ticker);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: alreadyAdded
|
||||
? null
|
||||
: () async {
|
||||
final watchlist = WatchlistProvider.of(context);
|
||||
await watchlist.addStock(ticker, name);
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
color: alreadyAdded
|
||||
? Theme.of(context).colorScheme.secondary.withValues(alpha: 0.5)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(ticker).semiBold,
|
||||
const Gap(2),
|
||||
Text(name).muted.small,
|
||||
],
|
||||
),
|
||||
),
|
||||
if (alreadyAdded) const Text('Added').muted.small,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
|
||||
import 'package:capstone_project/providers/settings.dart';
|
||||
import 'package:capstone_project/widgets/navbar.dart';
|
||||
import 'package:capstone_project/widgets/app_shell.dart';
|
||||
import 'package:capstone_project/widgets/panel_layout.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class SettingsPage extends StatefulWidget {
|
||||
|
||||
class SettingsPage extends StatefulWidget {
|
||||
static GoRoute route = GoRoute(
|
||||
path: "/settings",
|
||||
builder: (context, state) => SettingsPage()
|
||||
builder: (context, state) => SettingsPage(),
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -19,431 +19,80 @@ class SettingsPage extends StatefulWidget {
|
|||
|
||||
class _SettingsPageState extends State<SettingsPage> {
|
||||
|
||||
bool _isLoading = true;
|
||||
|
||||
String apiKey = "";
|
||||
List<Feed> feeds = [];
|
||||
String appStorageLocation = "";
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
// TODO: implement initState
|
||||
super.initState();
|
||||
|
||||
loadPage();
|
||||
}
|
||||
|
||||
void loadPage() async {
|
||||
SettingsProvider settings = SettingsProvider.of(context);
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
apiKey = settings.openAIApiKey;
|
||||
feeds = settings.feeds;
|
||||
appStorageLocation = settings.applicationStorageLocation;
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = SettingsProvider.of(context);
|
||||
final apiKey = settings.openRouterApiKey;
|
||||
final storagePath = settings.applicationStorageLocation;
|
||||
|
||||
if (_isLoading) {
|
||||
return Scaffold(
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
final fields = <PanelField>[
|
||||
|
||||
PanelField(
|
||||
section: 'API',
|
||||
label: const Text('OpenRouter key'),
|
||||
child: TextField(
|
||||
placeholder: const Text('sk-or-...'),
|
||||
initialValue: apiKey.isEmpty
|
||||
? ''
|
||||
: '${apiKey.substring(0, apiKey.length >= 8 ? 8 : apiKey.length)}xxxxxx',
|
||||
onChanged: (value) => settings.setOpenRouterApiKey(value),
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
|
||||
return Scaffold(
|
||||
headers: [
|
||||
AppBar(
|
||||
title: Text("Settings"),
|
||||
)
|
||||
],
|
||||
footers: [
|
||||
ProjNavBar(
|
||||
currentPage: "settings",
|
||||
)
|
||||
],
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 600,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
Text(
|
||||
"Open AI"
|
||||
).h4,
|
||||
|
||||
Gap(12),
|
||||
|
||||
Text(
|
||||
"API Key"
|
||||
).small.normal,
|
||||
|
||||
Gap(8),
|
||||
|
||||
TextField(
|
||||
placeholder: Text(
|
||||
"Enter your OpenAI API key"
|
||||
),
|
||||
initialValue: apiKey.substring(0, 8) + "xxxxxx (Redacted for security)" ,
|
||||
onChanged: (value) {
|
||||
SettingsProvider settings = SettingsProvider.of(context);
|
||||
|
||||
settings.setOpenAIApiKey(value);
|
||||
},
|
||||
PanelField(
|
||||
section: 'Application Data',
|
||||
label: const Text('Storage path'),
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
Expanded(
|
||||
child: Text(
|
||||
storagePath.isEmpty ? 'Default' : storagePath,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).colorScheme.mutedForeground,
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
if (apiKey.isEmpty)
|
||||
DestructiveBadge(
|
||||
child: Text(
|
||||
"API key is required to use AI features."
|
||||
),
|
||||
),
|
||||
|
||||
Gap(16),
|
||||
|
||||
Divider(),
|
||||
|
||||
Gap(16),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"News Feeds"
|
||||
).h4,
|
||||
|
||||
Gap(4),
|
||||
|
||||
Text(
|
||||
"Manage your RSS news feeds."
|
||||
).muted.small,
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Button.outline(
|
||||
child: Text(
|
||||
"Add Feed"
|
||||
),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AddFeedDialog()
|
||||
).then((value) {
|
||||
loadPage();
|
||||
});
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
|
||||
Gap(24),
|
||||
|
||||
for (Feed feed in feeds) ...[
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
feed.title.isNotEmpty ? feed.title : feed.url,
|
||||
style: TextStyle(
|
||||
decoration: feed.enabled ? TextDecoration.none : TextDecoration.lineThrough
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Builder(
|
||||
builder: (context2) {
|
||||
return GhostButton(
|
||||
density: ButtonDensity.dense,
|
||||
child: Icon(
|
||||
LucideIcons.ellipsis,
|
||||
|
||||
),
|
||||
onPressed: () {
|
||||
|
||||
|
||||
|
||||
showDropdown(
|
||||
context: context2,
|
||||
builder: (_) {
|
||||
return DropdownMenu(
|
||||
children: [
|
||||
MenuButton(
|
||||
child: Text(
|
||||
"Edit"
|
||||
),
|
||||
trailing: Icon(
|
||||
LucideIcons.filePen
|
||||
),
|
||||
onPressed: (_) {
|
||||
// Navigator.of(context).pop();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AddFeedDialog(
|
||||
existingFeed: feed,
|
||||
)
|
||||
).then((value) {
|
||||
loadPage();
|
||||
});
|
||||
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
),
|
||||
|
||||
Gap(4),
|
||||
|
||||
Switch(
|
||||
value: feed.enabled,
|
||||
onChanged: (value) {
|
||||
SettingsProvider settings = SettingsProvider.of(context);
|
||||
|
||||
settings.updateFeed(
|
||||
feed.id,
|
||||
enabled: value
|
||||
);
|
||||
|
||||
loadPage();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
SelectableText(
|
||||
feed.url,
|
||||
style: TextStyle(
|
||||
decoration: feed.enabled ? TextDecoration.none : TextDecoration.lineThrough
|
||||
),
|
||||
).muted.small,
|
||||
|
||||
Gap(12),
|
||||
],
|
||||
|
||||
Gap(12),
|
||||
|
||||
Divider(),
|
||||
|
||||
Gap(16),
|
||||
|
||||
Text(
|
||||
"Application Data"
|
||||
).h4,
|
||||
|
||||
Gap(12),
|
||||
|
||||
Text(
|
||||
"Application Storage Location"
|
||||
).small.normal,
|
||||
|
||||
Gap(8),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
placeholder: Text(
|
||||
"Select a diretory using Browse"
|
||||
),
|
||||
initialValue: appStorageLocation,
|
||||
enabled: false,
|
||||
),
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
Button.outline(
|
||||
child: Text(
|
||||
"Browse"
|
||||
),
|
||||
onPressed: () {
|
||||
|
||||
FilePicker.platform.getDirectoryPath().then((selectedPath) {
|
||||
if (selectedPath != null) {
|
||||
SettingsProvider settings = SettingsProvider.of(context);
|
||||
|
||||
settings.setApplicationStorageLocation(selectedPath);
|
||||
|
||||
loadPage();
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
},
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
IconButton.destructive(
|
||||
icon: Icon(
|
||||
LucideIcons.refreshCcw
|
||||
),
|
||||
onPressed: () async {
|
||||
SettingsProvider settings = SettingsProvider.of(context);
|
||||
|
||||
settings.setApplicationStorageLocation((await getApplicationDocumentsDirectory()).path);
|
||||
|
||||
loadPage();
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
|
||||
],
|
||||
).withMargin(
|
||||
all: 10
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Gap(8),
|
||||
|
||||
Button.outline(
|
||||
onPressed: () {
|
||||
FilePicker.platform.getDirectoryPath().then((path) {
|
||||
if (path != null) {
|
||||
settings.setApplicationStorageLocation(path);
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
},
|
||||
child: const Text('Browse'),
|
||||
),
|
||||
|
||||
const Gap(4),
|
||||
|
||||
IconButton.destructive(
|
||||
icon: const Icon(LucideIcons.refreshCcw),
|
||||
onPressed: () async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
settings.setApplicationStorageLocation(dir.path);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
).withPadding(all: 24),
|
||||
),
|
||||
|
||||
];
|
||||
|
||||
return AugorShell(
|
||||
titleTag: 'Settings',
|
||||
statusLeft: const StatusText('Augor · Settings'),
|
||||
child: SingleChildScrollView(
|
||||
child: PanelList(fields: fields),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AddFeedDialog extends StatelessWidget {
|
||||
|
||||
Feed? existingFeed;
|
||||
|
||||
AddFeedDialog({super.key, this.existingFeed});
|
||||
|
||||
InputKey feedTitleKey = InputKey("feed_title");
|
||||
InputKey feedUrlKey = InputKey("feed_url");
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
existingFeed == null ? "Add Feed" : "Edit Feed"
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 400,
|
||||
child: Form(
|
||||
onSubmit: (context, values) {
|
||||
String title = values[feedTitleKey] ?? "";
|
||||
String url = values[feedUrlKey] ?? "";
|
||||
|
||||
SettingsProvider settings = SettingsProvider.of(context);
|
||||
|
||||
if (existingFeed != null) {
|
||||
settings.updateFeed(
|
||||
existingFeed!.id,
|
||||
title: title,
|
||||
url: url
|
||||
);
|
||||
} else {
|
||||
settings.addFeed(Feed(
|
||||
title: title,
|
||||
url: url,
|
||||
enabled: true
|
||||
));
|
||||
}
|
||||
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
Text(
|
||||
"Add a custom RSS feed to your news sources."
|
||||
).muted,
|
||||
|
||||
Gap(16),
|
||||
|
||||
FormField(
|
||||
key: feedTitleKey,
|
||||
label: Text(
|
||||
"Title"
|
||||
),
|
||||
validator: ConditionalValidator((value) {
|
||||
if (value is String) {
|
||||
return value.trim().isNotEmpty;
|
||||
}
|
||||
return false;
|
||||
}, message: "Title cannot be empty"),
|
||||
child: TextField(
|
||||
initialValue: existingFeed?.title,
|
||||
),
|
||||
),
|
||||
|
||||
Gap(12),
|
||||
|
||||
FormField(
|
||||
key: feedUrlKey,
|
||||
label: Text(
|
||||
"Resource URL"
|
||||
),
|
||||
validator: ConditionalValidator((value) {
|
||||
if (value is String) {
|
||||
return value.trim().isNotEmpty;
|
||||
}
|
||||
return false;
|
||||
}, message: "URL cannot be empty"),
|
||||
child: TextField(
|
||||
initialValue: existingFeed?.url,
|
||||
)
|
||||
),
|
||||
|
||||
Gap(24),
|
||||
|
||||
Text(
|
||||
"Only use valid RSS feed URLs. Preferably only use sites you trust, and specifically business and financial news sources."
|
||||
),
|
||||
|
||||
Gap(24),
|
||||
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
|
||||
Button.outline(
|
||||
child: Text(
|
||||
"Cancel"
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
SubmitButton(
|
||||
child: Text(
|
||||
existingFeed != null ? "Update Feed" : "Add Feed"
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
788
lib/pages/stock_dashboard.dart
Normal file
788
lib/pages/stock_dashboard.dart
Normal 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!,
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,115 +1,14 @@
|
|||
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:uid/uid.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'dart:io';
|
||||
|
||||
class SettingsProvider extends ChangeNotifier {
|
||||
|
||||
String _OPENAI_API_KEY = "";
|
||||
String get openAIApiKey => _OPENAI_API_KEY;
|
||||
|
||||
List<Feed> _feeds = [
|
||||
// Major Business News
|
||||
Feed(
|
||||
title: "Business Insider",
|
||||
url: "https://feeds.businessinsider.com/custom/all"
|
||||
),
|
||||
Feed(
|
||||
title: "BBC Business",
|
||||
url: "https://feeds.bbci.co.uk/news/business/rss.xml"
|
||||
),
|
||||
Feed(
|
||||
title: "Reuters Business",
|
||||
url: "https://www.reutersagency.com/feed/?taxonomy=best-topics&post_type=best"
|
||||
),
|
||||
Feed(
|
||||
title: "Financial Times",
|
||||
url: "https://www.ft.com/?format=rss"
|
||||
),
|
||||
Feed(
|
||||
title: "Bloomberg",
|
||||
url: "https://feeds.bloomberg.com/markets/news.rss"
|
||||
),
|
||||
|
||||
// Market-Specific
|
||||
Feed(
|
||||
title: "MarketWatch",
|
||||
url: "https://feeds.marketwatch.com/marketwatch/topstories/"
|
||||
),
|
||||
Feed(
|
||||
title: "Seeking Alpha",
|
||||
url: "https://seekingalpha.com/feed.xml"
|
||||
),
|
||||
Feed(
|
||||
title: "Yahoo Finance",
|
||||
url: "https://finance.yahoo.com/news/rssindex"
|
||||
),
|
||||
Feed(
|
||||
title: "CNBC",
|
||||
url: "https://www.cnbc.com/id/100003114/device/rss/rss.html"
|
||||
),
|
||||
Feed(
|
||||
title: "The Wall Street Journal",
|
||||
url: "https://feeds.a.dj.com/rss/RSSMarketsMain.xml"
|
||||
),
|
||||
|
||||
// Tech Business
|
||||
Feed(
|
||||
title: "TechCrunch",
|
||||
url: "https://techcrunch.com/feed/"
|
||||
),
|
||||
Feed(
|
||||
title: "The Verge",
|
||||
url: "https://www.theverge.com/rss/index.xml"
|
||||
),
|
||||
Feed(
|
||||
title: "Ars Technica",
|
||||
url: "https://feeds.arstechnica.com/arstechnica/index"
|
||||
),
|
||||
|
||||
// Company News
|
||||
Feed(
|
||||
title: "Fortune",
|
||||
url: "https://fortune.com/feed"
|
||||
),
|
||||
Feed(
|
||||
title: "Forbes Business",
|
||||
url: "https://www.forbes.com/business/feed/"
|
||||
),
|
||||
Feed(
|
||||
title: "Inc Magazine",
|
||||
url: "https://www.inc.com/rss"
|
||||
),
|
||||
|
||||
// Industry-Specific
|
||||
Feed(
|
||||
title: "Retail Dive",
|
||||
url: "https://www.retaildive.com/feeds/news/"
|
||||
),
|
||||
Feed(
|
||||
title: "Manufacturing Dive",
|
||||
url: "https://www.manufacturingdive.com/feeds/news/"
|
||||
),
|
||||
Feed(
|
||||
title: "Banking Dive",
|
||||
url: "https://www.bankingdive.com/feeds/news/"
|
||||
),
|
||||
|
||||
// Economic News
|
||||
Feed(
|
||||
title: "The Economist",
|
||||
url: "https://www.economist.com/finance-and-economics/rss.xml"
|
||||
),
|
||||
Feed(
|
||||
title: "Federal Reserve News",
|
||||
url: "https://www.federalreserve.gov/feeds/press_all.xml"
|
||||
),
|
||||
]; // List of rss feed URLs
|
||||
List<Feed> get feeds => List.unmodifiable(_feeds);
|
||||
String _openRouterApiKey = "";
|
||||
String get openRouterApiKey => _openRouterApiKey;
|
||||
|
||||
late String _applicationStorageLocation;
|
||||
String get applicationStorageLocation => _applicationStorageLocation;
|
||||
|
|
@ -119,42 +18,13 @@ class SettingsProvider extends ChangeNotifier {
|
|||
}
|
||||
|
||||
Future<void> load() async {
|
||||
// get documents directory dynamicly
|
||||
final Directory appDocDir = await getApplicationDocumentsDirectory();
|
||||
_applicationStorageLocation = appDocDir.path;
|
||||
|
||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
|
||||
_OPENAI_API_KEY = prefs.getString('openai_api_key') ?? _OPENAI_API_KEY;
|
||||
|
||||
List<String>? feedStrings = prefs.getStringList('feeds');
|
||||
if (feedStrings != null) {
|
||||
_feeds = feedStrings.map((fStr) {
|
||||
try {
|
||||
return Feed.fromJson(jsonDecode(fStr));
|
||||
} catch (e) {
|
||||
// old format, parse manually
|
||||
Map<String, dynamic> json = {};
|
||||
fStr.substring(1, fStr.length - 1).split(', ').forEach((pair) {
|
||||
List<String> keyValue = pair.split(': ');
|
||||
String key = keyValue[0];
|
||||
String value = keyValue[1];
|
||||
|
||||
// convert bool strings to actual bools
|
||||
if (value == 'true') {
|
||||
json[key] = true;
|
||||
} else if (value == 'false') {
|
||||
json[key] = false;
|
||||
} else {
|
||||
json[key] = value;
|
||||
}
|
||||
});
|
||||
return Feed.fromJson(json);
|
||||
}
|
||||
}).toList();
|
||||
}
|
||||
|
||||
_applicationStorageLocation = prefs.getString('application_storage_location') ?? applicationStorageLocation;
|
||||
_openRouterApiKey = prefs.getString('openrouter_api_key') ?? prefs.getString('openai_api_key') ?? _openRouterApiKey;
|
||||
_applicationStorageLocation = prefs.getString('application_storage_location') ?? _applicationStorageLocation;
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
|
@ -162,42 +32,12 @@ class SettingsProvider extends ChangeNotifier {
|
|||
Future<void> save() async {
|
||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
|
||||
await prefs.setString('openai_api_key', _OPENAI_API_KEY);
|
||||
await prefs.setStringList('feeds', _feeds.map((f) => jsonEncode(f.toJson())).toList());
|
||||
await prefs.setString('openrouter_api_key', _openRouterApiKey);
|
||||
await prefs.setString('application_storage_location', _applicationStorageLocation);
|
||||
}
|
||||
|
||||
void setOpenAIApiKey(String key) {
|
||||
_OPENAI_API_KEY = key;
|
||||
save();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void addFeed(Feed feed) {
|
||||
_feeds.add(feed);
|
||||
save();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Feed updateFeed(int id, {String? title, String? url, bool? enabled}) {
|
||||
for (int i = 0; i < _feeds.length; i++) {
|
||||
if (_feeds[i].id == id) {
|
||||
Feed updatedFeed = Feed(
|
||||
url: url ?? _feeds[i].url,
|
||||
title: title ?? _feeds[i].title,
|
||||
enabled: enabled ?? _feeds[i].enabled,
|
||||
);
|
||||
_feeds[i] = updatedFeed;
|
||||
save();
|
||||
notifyListeners();
|
||||
return updatedFeed;
|
||||
}
|
||||
}
|
||||
throw Exception("Feed with id $id not found");
|
||||
}
|
||||
|
||||
void removeFeed(Feed feed) {
|
||||
_feeds.removeWhere((f) => f.url == feed.url);
|
||||
void setOpenRouterApiKey(String key) {
|
||||
_openRouterApiKey = key;
|
||||
save();
|
||||
notifyListeners();
|
||||
}
|
||||
|
|
@ -209,27 +49,3 @@ class SettingsProvider extends ChangeNotifier {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
class Feed {
|
||||
|
||||
final String url;
|
||||
final String title;
|
||||
final bool enabled;
|
||||
|
||||
Feed({required this.url, required this.title, this.enabled = true});
|
||||
|
||||
Feed.fromJson(Map<String, dynamic> json)
|
||||
: url = json['url'],
|
||||
title = json['title'],
|
||||
enabled = json['enabled'] ?? true;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'url': url,
|
||||
'title': title,
|
||||
'enabled': enabled,
|
||||
};
|
||||
|
||||
int get id {
|
||||
return url.hashCode ^ title.hashCode;
|
||||
}
|
||||
}
|
||||
91
lib/providers/watchlist.dart
Normal file
91
lib/providers/watchlist.dart
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:capstone_project/models/event_signal.dart';
|
||||
import 'package:capstone_project/models/watched_stock.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class WatchlistProvider extends ChangeNotifier {
|
||||
static const String _storageKey = 'watched_stocks';
|
||||
|
||||
List<WatchedStock> _stocks = [];
|
||||
List<WatchedStock> get stocks => List.unmodifiable(_stocks);
|
||||
|
||||
static WatchlistProvider of(BuildContext context) {
|
||||
return Provider.of<WatchlistProvider>(context, listen: false);
|
||||
}
|
||||
|
||||
Future<void> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final stockStrings = prefs.getStringList(_storageKey) ?? [];
|
||||
|
||||
_stocks = stockStrings.map((stockString) {
|
||||
return WatchedStock.fromJson(jsonDecode(stockString));
|
||||
}).toList();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> save() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(
|
||||
_storageKey,
|
||||
_stocks.map((stock) => jsonEncode(stock.toJson())).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> addStock(String ticker, String companyName) async {
|
||||
final normalizedTicker = ticker.trim().toUpperCase();
|
||||
final normalizedCompanyName = companyName.trim();
|
||||
|
||||
if (normalizedTicker.isEmpty || normalizedCompanyName.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final existingIndex = _stocks.indexWhere((stock) => stock.ticker == normalizedTicker);
|
||||
if (existingIndex != -1) {
|
||||
_stocks[existingIndex] = WatchedStock(
|
||||
ticker: normalizedTicker,
|
||||
companyName: normalizedCompanyName,
|
||||
latestSignal: _stocks[existingIndex].latestSignal,
|
||||
signalHistory: _stocks[existingIndex].signalHistory,
|
||||
);
|
||||
} else {
|
||||
_stocks.add(
|
||||
WatchedStock(
|
||||
ticker: normalizedTicker,
|
||||
companyName: normalizedCompanyName,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await save();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> removeStock(String ticker) async {
|
||||
_stocks.removeWhere((stock) => stock.ticker == ticker.trim().toUpperCase());
|
||||
await save();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> updateSignals(String ticker, List<EventSignal> signals) async {
|
||||
final normalizedTicker = ticker.trim().toUpperCase();
|
||||
final stockIndex = _stocks.indexWhere((stock) => stock.ticker == normalizedTicker);
|
||||
|
||||
if (stockIndex == -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
_stocks[stockIndex] = WatchedStock(
|
||||
ticker: _stocks[stockIndex].ticker,
|
||||
companyName: _stocks[stockIndex].companyName,
|
||||
latestSignal: signals.isNotEmpty ? signals.first : null,
|
||||
signalHistory: List<EventSignal>.from(signals),
|
||||
);
|
||||
|
||||
await save();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
117
lib/services/duriin_service.dart
Normal file
117
lib/services/duriin_service.dart
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:capstone_project/utils/agrigator.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
|
||||
const _baseUrl = 'http://duriin.imbenji.net';
|
||||
|
||||
|
||||
// pair of (article, semantic distance from the query / seed). distance is
|
||||
// null when the underlying endpoint doesnt return one.
|
||||
class SimilarHit {
|
||||
final FeedItem item;
|
||||
final double? distance;
|
||||
|
||||
SimilarHit({required this.item, this.distance});
|
||||
}
|
||||
|
||||
|
||||
class DuriinService {
|
||||
|
||||
Future<List<FeedItem>> search({
|
||||
required String ticker,
|
||||
required String companyName,
|
||||
required DateTime from,
|
||||
required DateTime to,
|
||||
int limit = 100,
|
||||
}) async {
|
||||
final fromStr = from.toUtc().toIso8601String();
|
||||
final toStr = to.toUtc().toIso8601String();
|
||||
|
||||
|
||||
// build the keyword set: the ticker itself plus the company brand name
|
||||
// (first token of the registered name — "NVIDIA Corporation" -> "NVIDIA",
|
||||
// "Apple Inc" -> "Apple"). the full legal name rarely appears verbatim in
|
||||
// articles so matching on it would miss most hits.
|
||||
final keywords = <String>{};
|
||||
final t = ticker.trim();
|
||||
if (t.isNotEmpty) keywords.add(t);
|
||||
|
||||
final brand = companyName.trim().split(RegExp(r"\s+")).firstOrNull ?? "";
|
||||
if (brand.isNotEmpty) keywords.add(brand);
|
||||
|
||||
final hits = await _fetchHits(queryParams: {
|
||||
'keyword': keywords.toList(),
|
||||
'keyword_mode': 'or',
|
||||
'from': fromStr,
|
||||
'to': toStr,
|
||||
'limit': '$limit',
|
||||
'order': 'newest',
|
||||
});
|
||||
|
||||
final items = hits.map((h) => h.item).toList();
|
||||
|
||||
print('Duriin: ${items.length} articles for $ticker / $companyName (kw=${keywords.join(",")})');
|
||||
return items;
|
||||
}
|
||||
|
||||
|
||||
// neighbours of a given article via the vector index. used by the event
|
||||
// clusterer to build real clusters instead of arbitrary time slices.
|
||||
Future<List<SimilarHit>> findSimilar(int articleId, {int limit = 25}) async {
|
||||
return _fetchHits(queryParams: {
|
||||
'similar_to_article': '$articleId',
|
||||
'limit': '$limit',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Future<List<SimilarHit>> _fetchHits({required Map<String, dynamic> queryParams}) async {
|
||||
final uri = Uri.parse(_baseUrl).replace(
|
||||
path: '/articles',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
try {
|
||||
final response = await http.get(uri);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
print('Duriin: HTTP ${response.statusCode} for $uri');
|
||||
return [];
|
||||
}
|
||||
|
||||
final body = response.body.trim();
|
||||
if (body.isEmpty) return [];
|
||||
|
||||
final list = jsonDecode(body) as List<dynamic>;
|
||||
|
||||
return list.map((raw) {
|
||||
final m = raw as Map<String, dynamic>;
|
||||
|
||||
final id = m['id'] is int
|
||||
? m['id'] as int
|
||||
: int.tryParse('${m['id']}');
|
||||
|
||||
final item = FeedItem(
|
||||
id: id,
|
||||
title: (m['title'] ?? '').toString(),
|
||||
description: (m['description'] ?? '').toString(),
|
||||
content: (m['content'] ?? '').toString(),
|
||||
link: (m['url'] ?? '').toString(),
|
||||
source: (m['source'] ?? '').toString().isNotEmpty ? m['source'].toString() : null,
|
||||
pubDate: m['pub_date'] != null ? DateTime.tryParse(m['pub_date'].toString()) : null,
|
||||
);
|
||||
|
||||
final distRaw = m['distance'];
|
||||
final distance = distRaw is num ? distRaw.toDouble() : null;
|
||||
|
||||
return SimilarHit(item: item, distance: distance);
|
||||
}).where((h) => h.item.title.isNotEmpty && h.item.link.isNotEmpty).toList();
|
||||
|
||||
} catch (e) {
|
||||
print('Duriin fetch error: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
19
lib/services/news_search_service.dart
Normal file
19
lib/services/news_search_service.dart
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import 'package:capstone_project/services/duriin_service.dart';
|
||||
import 'package:capstone_project/utils/agrigator.dart';
|
||||
|
||||
|
||||
class NewsSearchService {
|
||||
final _duriin = DuriinService();
|
||||
|
||||
Future<List<FeedItem>> search({
|
||||
required String ticker,
|
||||
required String companyName,
|
||||
required DateTime from,
|
||||
required DateTime to,
|
||||
}) => _duriin.search(
|
||||
ticker: ticker,
|
||||
companyName: companyName,
|
||||
from: from,
|
||||
to: to,
|
||||
);
|
||||
}
|
||||
82
lib/services/stock_price_service.dart
Normal file
82
lib/services/stock_price_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,268 +1,60 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:xml/xml.dart';
|
||||
import 'openai.dart';
|
||||
|
||||
const String KEYWORDS = "Business news corporate earnings revenue profit stock market trading equity shares NYSE NASDAQ stock prices quarterly results annual reports CEO announcements executive leadership management changes board directors company strategy mergers acquisitions takeovers buyouts partnerships joint ventures business deals IPO initial public offerings venture capital funding investment rounds valuation startup unicorn enterprise technology product launches innovation R&D research development market expansion international business global markets trade agreements tariffs import export supply chain logistics manufacturing production operations facilities factories plants workforce hiring layoffs restructuring downsizing labor unions strikes employee relations workplace compensation benefits corporate governance shareholder activism proxy fights dividends stock buybacks analyst ratings price targets market capitalization revenue growth profit margins EBITDA cash flow debt financing credit ratings bonds corporate strategy competitive advantage market share industry trends sector analysis retail consumer goods e-commerce technology software hardware semiconductors pharmaceuticals biotech healthcare energy oil gas renewables automotive electric vehicles aerospace defense banking financial services insurance real estate construction infrastructure telecommunications media entertainment streaming gaming hospitality travel transportation logistics shipping airlines regulatory compliance antitrust competition policy lawsuits litigation settlements data breaches cybersecurity intellectual property patents trademarks brand value customer acquisition market positioning business models revenue streams profitability sustainability ESG environmental social governance";
|
||||
List<double>? KEYWORD_EMBEDDINGS;
|
||||
|
||||
class FeedItem {
|
||||
final int? id;
|
||||
final String title;
|
||||
final String description;
|
||||
|
||||
// the extracted article body — may be empty for older rows or sources
|
||||
// that havent been backfilled yet.
|
||||
final String content;
|
||||
|
||||
final String link;
|
||||
List<double>? embedding;
|
||||
final String? source;
|
||||
final DateTime? pubDate;
|
||||
|
||||
FeedItem({
|
||||
this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
this.content = "",
|
||||
required this.link,
|
||||
this.embedding,
|
||||
this.source,
|
||||
this.pubDate,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "FeedItem(title: $title, link: $link)";
|
||||
}
|
||||
String toString() => 'FeedItem(title: $title, link: $link)';
|
||||
|
||||
FeedItem.fromJson(Map<String, dynamic> json)
|
||||
: title = json["title"],
|
||||
description = json["description"],
|
||||
link = json["link"],
|
||||
embedding = json["embedding"] != null
|
||||
? (json["embedding"] as List).map<double>((e) => (e as num).toDouble()).toList()
|
||||
: null;
|
||||
: id = json['id'] is int ? json['id'] as int : int.tryParse('${json['id']}'),
|
||||
title = json['title'] ?? '',
|
||||
description = json['description'] ?? '',
|
||||
content = (json['content'] ?? '').toString(),
|
||||
link = json['link'] ?? '',
|
||||
source = json['source'],
|
||||
pubDate = json['pub_date'] != null ? DateTime.tryParse(json['pub_date']) : null;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
"title": title,
|
||||
"description": description,
|
||||
"link": link,
|
||||
if (embedding != null) "embedding": embedding,
|
||||
if (id != null) 'id': id,
|
||||
'title': title,
|
||||
'description': description,
|
||||
if (content.isNotEmpty) 'content': content,
|
||||
'link': link,
|
||||
if (source != null) 'source': source,
|
||||
if (pubDate != null) 'pub_date': pubDate!.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
List<FeedItem> parseRssFeed(String rssXml) {
|
||||
final document = XmlDocument.parse(rssXml);
|
||||
|
||||
// find items in the RSS structre
|
||||
final items = document.findAllElements("item");
|
||||
|
||||
return items.map((item) {
|
||||
final title = item.findElements("title").firstOrNull?.innerText.trim() ?? "Untitled";
|
||||
final link = item.findElements("link").firstOrNull?.innerText ?? "";
|
||||
final description = item.findElements("description").firstOrNull?.innerText.trim() ?? "";
|
||||
|
||||
|
||||
return FeedItem(
|
||||
title: title,
|
||||
link: link,
|
||||
description: description,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
List<FeedItem> parseAtomFeed(String atomXml) {
|
||||
final document = XmlDocument.parse(atomXml);
|
||||
|
||||
// find entrys in atom feed
|
||||
final entries = document.findAllElements("entry");
|
||||
|
||||
return entries.map((entry) {
|
||||
final title = entry.findElements("title").firstOrNull?.innerText.trim() ?? "Untitled";
|
||||
final linkElement = entry.findElements("link").firstOrNull;
|
||||
final link = linkElement?.getAttribute("href") ?? "";
|
||||
final summary = entry.findElements("summary").firstOrNull?.innerText.trim();
|
||||
final content = entry.findElements("content").firstOrNull?.innerText.trim();
|
||||
|
||||
final description = (summary ?? content ?? "").trim();
|
||||
|
||||
return FeedItem(
|
||||
title: title,
|
||||
link: link,
|
||||
description: description,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
List<FeedItem> parseFeed(String feedXml) {
|
||||
final document = XmlDocument.parse(feedXml);
|
||||
|
||||
// Check if it's an Atom feed
|
||||
if (document.findAllElements('feed').isNotEmpty) {
|
||||
return parseAtomFeed(feedXml);
|
||||
}
|
||||
|
||||
// Check if it's an RSS feed
|
||||
if (document.findAllElements('rss').isNotEmpty ||
|
||||
document.findAllElements('channel').isNotEmpty) {
|
||||
return parseRssFeed(feedXml);
|
||||
}
|
||||
|
||||
// Unknown feed format
|
||||
throw FormatException('Unknown feed format. Expected RSS or Atom.');
|
||||
}
|
||||
|
||||
Future<List<FeedItem>> fetchFeed(Uri feedUri) async {
|
||||
final response = await http.get(feedUri);
|
||||
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception("Failed to fetch feed: ${response.statusCode}");
|
||||
}
|
||||
|
||||
// parse the XML response
|
||||
return parseFeed(response.body);
|
||||
}
|
||||
|
||||
Future<List<FeedItem>> fetchFeeds(List<Uri> feedUris) async {
|
||||
List<FeedItem> allItems = [];
|
||||
|
||||
final results = await Future.wait(
|
||||
feedUris.map((uri) => fetchFeed(uri).catchError((e) {
|
||||
print("Error fetching feed $uri: $e");
|
||||
return <FeedItem>[];
|
||||
}))
|
||||
);
|
||||
|
||||
for (final items in results) {
|
||||
allItems.addAll(items);
|
||||
}
|
||||
|
||||
return allItems;
|
||||
}
|
||||
|
||||
|
||||
// generete embeddng for a feed item
|
||||
Future<void> generateEmbedding(FeedItem item, String apiKey) async {
|
||||
final openai = OpenAI(apiKey: apiKey);
|
||||
// median pub date of a cluster — falls back to now if none have dates
|
||||
DateTime medianPubDate(List<FeedItem> articles) {
|
||||
final dates = articles
|
||||
.where((a) => a.pubDate != null)
|
||||
.map((a) => a.pubDate!)
|
||||
.toList()
|
||||
..sort();
|
||||
|
||||
// combine tittle and descriptin
|
||||
final textToEmbed = "${item.title} ${item.description}";
|
||||
|
||||
try {
|
||||
final response = await openai.embeddings.create(
|
||||
model: "text-embedding-3-small",
|
||||
input: textToEmbed,
|
||||
);
|
||||
|
||||
if (response.data.isNotEmpty) {
|
||||
item.embedding = response.data.first.embedding;
|
||||
}
|
||||
} catch (e) {
|
||||
print("Error generatng embedding: $e");
|
||||
} finally {
|
||||
openai.dispose();
|
||||
}
|
||||
if (dates.isEmpty) return DateTime.now();
|
||||
return dates[dates.length ~/ 2];
|
||||
}
|
||||
|
||||
// generate embedings for multiple feed items
|
||||
Future<void> generateEmbeddings(List<FeedItem> items, String apiKey) async {
|
||||
await Future.wait(
|
||||
items.map((item) => generateEmbedding(item, apiKey))
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> generateKeywordEmbeddings(String apiKey) async {
|
||||
|
||||
if (KEYWORD_EMBEDDINGS != null) {
|
||||
return; // already generated
|
||||
}
|
||||
|
||||
final openai = OpenAI(apiKey: apiKey);
|
||||
|
||||
try {
|
||||
final response = await openai.embeddings.create(
|
||||
model: "text-embedding-3-small",
|
||||
input: KEYWORDS,
|
||||
);
|
||||
|
||||
if (response.data.isNotEmpty) {
|
||||
KEYWORD_EMBEDDINGS = response.data.first.embedding;
|
||||
}
|
||||
} catch (e) {
|
||||
print("Error generating keyword embeddings: $e");
|
||||
} finally {
|
||||
openai.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
bool isFeedItemRelevant(FeedItem item, [double threshold = 0.25]) {
|
||||
if (item.embedding == null || KEYWORD_EMBEDDINGS == null) {
|
||||
throw Exception("Embeddings not available for comparison.");
|
||||
}
|
||||
|
||||
double similarity = cosineSimilarity(item.embedding!, KEYWORD_EMBEDDINGS!);
|
||||
|
||||
return similarity >= threshold;
|
||||
}
|
||||
|
||||
double cosineSimilarity(List<double> vecA, List<double> vecB) {
|
||||
if (vecA.length != vecB.length) {
|
||||
throw ArgumentError("Vectors must be of the same length");
|
||||
}
|
||||
|
||||
double dotProduct = 0.0;
|
||||
double magnitudeA = 0.0;
|
||||
double magnitudeB = 0.0;
|
||||
|
||||
for (int i = 0; i < vecA.length; i++) {
|
||||
dotProduct += vecA[i] * vecB[i];
|
||||
magnitudeA += vecA[i] * vecA[i];
|
||||
magnitudeB += vecB[i] * vecB[i];
|
||||
}
|
||||
|
||||
if (magnitudeA == 0 || magnitudeB == 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return dotProduct / (sqrt(magnitudeA) * sqrt(magnitudeB));
|
||||
}
|
||||
|
||||
List<List<FeedItem>> groupFeedItemsByEvent(List<FeedItem> items, [double similarityThreshold = 0.7]) {
|
||||
// Track which group each item belongs to and with what similarity
|
||||
Map<int, ({int groupIndex, double similarity})> itemGrouping = {};
|
||||
List<List<FeedItem>> groupedItems = [];
|
||||
|
||||
for (int i = 0; i < items.length; i++) {
|
||||
// Create a new group with item i as the anchor
|
||||
List<FeedItem> currentGroup = [items[i]];
|
||||
int currentGroupIndex = groupedItems.length;
|
||||
|
||||
// item i belongs to its own group with similarity 1.0
|
||||
itemGrouping[i] = (groupIndex: currentGroupIndex, similarity: 1.0);
|
||||
|
||||
// Check all later items
|
||||
for (int j = i + 1; j < items.length; j++) {
|
||||
double similarity = cosineSimilarity(
|
||||
items[i].embedding!,
|
||||
items[j].embedding!,
|
||||
);
|
||||
|
||||
if (similarity >= similarityThreshold) {
|
||||
// Check if j should join this group
|
||||
if (!itemGrouping.containsKey(j)) {
|
||||
// j hasn't been grouped yet, add it
|
||||
currentGroup.add(items[j]);
|
||||
itemGrouping[j] = (groupIndex: currentGroupIndex, similarity: similarity);
|
||||
} else if (similarity > itemGrouping[j]!.similarity) {
|
||||
// j is in another group but this is a better match
|
||||
// Remove from old group
|
||||
int oldGroupIndex = itemGrouping[j]!.groupIndex;
|
||||
groupedItems[oldGroupIndex].remove(items[j]);
|
||||
|
||||
// Add to this group
|
||||
currentGroup.add(items[j]);
|
||||
itemGrouping[j] = (groupIndex: currentGroupIndex, similarity: similarity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
groupedItems.add(currentGroup);
|
||||
}
|
||||
|
||||
// Filter out empty groups (items may have been moved out)
|
||||
return groupedItems.where((group) => group.isNotEmpty).toList();
|
||||
}
|
||||
154
lib/utils/event_clusterer.dart
Normal file
154
lib/utils/event_clusterer.dart
Normal 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.58–0.62, different-event-same-
|
||||
// topic pairs start around 0.70+. tighten if clusters start merging distinct
|
||||
// events, loosen if obvious same-event stories end up as singletons.
|
||||
final double distanceThreshold;
|
||||
|
||||
// hard cap on articles per cluster — keeps prompt size predictable
|
||||
final int maxClusterSize;
|
||||
|
||||
// how many neighbours to ask the api for per seed
|
||||
final int neighbourFetchLimit;
|
||||
|
||||
EventClusterer({
|
||||
DuriinService? duriin,
|
||||
this.distanceThreshold = 0.60,
|
||||
this.maxClusterSize = 10,
|
||||
this.neighbourFetchLimit = 25,
|
||||
}) : _duriin = duriin ?? DuriinService();
|
||||
|
||||
|
||||
Future<List<EventCluster>> cluster(List<FeedItem> articles) async {
|
||||
if (articles.isEmpty) return [];
|
||||
|
||||
// index by id for fast membership checks when neighbours come back
|
||||
final byId = <int, FeedItem>{};
|
||||
final withoutId = <FeedItem>[];
|
||||
|
||||
for (final a in articles) {
|
||||
if (a.id != null) {
|
||||
byId[a.id!] = a;
|
||||
} else {
|
||||
withoutId.add(a);
|
||||
}
|
||||
}
|
||||
|
||||
// work through newest first so the first signal surfaced is the freshest
|
||||
final queue = byId.values.toList()
|
||||
..sort((a, b) {
|
||||
final da = a.pubDate ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
final db = b.pubDate ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
return db.compareTo(da);
|
||||
});
|
||||
|
||||
final clustered = <int>{};
|
||||
final clusters = <EventCluster>[];
|
||||
|
||||
for (final seed in queue) {
|
||||
if (clustered.contains(seed.id)) continue;
|
||||
|
||||
final neighbours = await _duriin.findSimilar(
|
||||
seed.id!,
|
||||
limit: neighbourFetchLimit,
|
||||
);
|
||||
|
||||
|
||||
// keep only neighbours we actually fetched (same ticker / window)
|
||||
// and that are close enough to count as the same event.
|
||||
final members = <FeedItem>[seed];
|
||||
final distances = <int, double>{};
|
||||
|
||||
// dedupe just in case the api returns the seed in its own neighbour list
|
||||
final memberIds = <int>{seed.id!};
|
||||
|
||||
for (final hit in neighbours) {
|
||||
final nid = hit.item.id;
|
||||
if (nid == null) continue;
|
||||
if (nid == seed.id) continue;
|
||||
if (memberIds.contains(nid)) continue;
|
||||
|
||||
final inWindow = byId[nid];
|
||||
if (inWindow == null) continue;
|
||||
|
||||
final d = hit.distance;
|
||||
if (d == null) continue;
|
||||
if (d > distanceThreshold) continue;
|
||||
|
||||
members.add(inWindow);
|
||||
distances[nid] = d;
|
||||
memberIds.add(nid);
|
||||
|
||||
if (members.length >= maxClusterSize) break;
|
||||
}
|
||||
|
||||
for (final id in memberIds) {
|
||||
clustered.add(id);
|
||||
}
|
||||
|
||||
clusters.add(EventCluster(
|
||||
seed: seed,
|
||||
articles: members,
|
||||
distancesFromSeed: distances,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
// articles with no id (shouldnt happen post-api-update, but just in case)
|
||||
// each becomes its own singleton cluster so we never silently drop them.
|
||||
for (final orphan in withoutId) {
|
||||
clusters.add(EventCluster(
|
||||
seed: orphan,
|
||||
articles: [orphan],
|
||||
distancesFromSeed: const {},
|
||||
));
|
||||
}
|
||||
|
||||
return clusters;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +1,19 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// OpenAI API client for Dart
|
||||
class OpenAI {
|
||||
/// OpenRouter API client for Dart
|
||||
class OpenRouter {
|
||||
final String apiKey;
|
||||
final String baseUrl;
|
||||
final http.Client _client;
|
||||
|
||||
OpenAI({
|
||||
OpenRouter({
|
||||
required this.apiKey,
|
||||
this.baseUrl = 'https://api.openai.com/v1',
|
||||
this.baseUrl = 'https://openrouter.ai/api/v1',
|
||||
http.Client? client,
|
||||
}) : _client = client ?? http.Client();
|
||||
|
||||
/// Access to chat completions API
|
||||
ChatCompletions get chat => ChatCompletions(this);
|
||||
|
||||
/// Access to embeddings API
|
||||
Embeddings get embeddings => Embeddings(this);
|
||||
|
||||
void dispose() {
|
||||
|
|
@ -25,26 +21,19 @@ class OpenAI {
|
|||
}
|
||||
}
|
||||
|
||||
/// Chat completions API
|
||||
class ChatCompletions {
|
||||
final OpenAI _openai;
|
||||
final OpenRouter _openRouter;
|
||||
|
||||
ChatCompletions(this._openai);
|
||||
ChatCompletions(this._openRouter);
|
||||
|
||||
/// Access to completions endpoint
|
||||
Completions get completions => Completions(_openai);
|
||||
Completions get completions => Completions(_openRouter);
|
||||
}
|
||||
|
||||
/// Completions endpoint
|
||||
class Completions {
|
||||
final OpenAI _openai;
|
||||
final OpenRouter _openRouter;
|
||||
|
||||
Completions(this._openai);
|
||||
Completions(this._openRouter);
|
||||
|
||||
/// Create a chat completion
|
||||
///
|
||||
/// If [stream] is true, returns a Stream of ChatCompletionChunk
|
||||
/// If [stream] is false, returns a single ChatCompletion
|
||||
Future<dynamic> create({
|
||||
required String model,
|
||||
required List<dynamic> messages,
|
||||
|
|
@ -84,17 +73,17 @@ class Completions {
|
|||
}
|
||||
|
||||
Future<ChatCompletion> _createCompletion(Map<String, dynamic> body) async {
|
||||
final response = await _openai._client.post(
|
||||
Uri.parse('${_openai.baseUrl}/chat/completions'),
|
||||
final response = await _openRouter._client.post(
|
||||
Uri.parse('${_openRouter.baseUrl}/chat/completions'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ${_openai.apiKey}',
|
||||
'Authorization': 'Bearer ${_openRouter.apiKey}',
|
||||
},
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw OpenAIException(
|
||||
throw OpenRouterException(
|
||||
statusCode: response.statusCode,
|
||||
message: response.body,
|
||||
);
|
||||
|
|
@ -107,21 +96,21 @@ class Completions {
|
|||
Map<String, dynamic> body) async* {
|
||||
final request = http.Request(
|
||||
'POST',
|
||||
Uri.parse('${_openai.baseUrl}/chat/completions'),
|
||||
Uri.parse('${_openRouter.baseUrl}/chat/completions'),
|
||||
);
|
||||
|
||||
request.headers.addAll({
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ${_openai.apiKey}',
|
||||
'Authorization': 'Bearer ${_openRouter.apiKey}',
|
||||
});
|
||||
|
||||
request.body = jsonEncode(body);
|
||||
|
||||
final streamedResponse = await _openai._client.send(request);
|
||||
final streamedResponse = await _openRouter._client.send(request);
|
||||
|
||||
if (streamedResponse.statusCode != 200) {
|
||||
final body = await streamedResponse.stream.bytesToString();
|
||||
throw OpenAIException(
|
||||
throw OpenRouterException(
|
||||
statusCode: streamedResponse.statusCode,
|
||||
message: body,
|
||||
);
|
||||
|
|
@ -133,10 +122,10 @@ class Completions {
|
|||
|
||||
await for (final line in stream) {
|
||||
if (line.isEmpty) continue;
|
||||
if (line.startsWith(':')) continue; // Skip comments
|
||||
if (line.startsWith(':')) continue;
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
|
||||
final data = line.substring(6); // Remove 'data: ' prefix
|
||||
final data = line.substring(6);
|
||||
|
||||
if (data == '[DONE]') {
|
||||
break;
|
||||
|
|
@ -146,14 +135,12 @@ class Completions {
|
|||
final json = jsonDecode(data);
|
||||
yield ChatCompletionChunk.fromJson(json);
|
||||
} catch (e) {
|
||||
// Skip malformed chunks
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Chat message
|
||||
class ChatMessage {
|
||||
final String role;
|
||||
final String content;
|
||||
|
|
@ -183,7 +170,6 @@ class ChatMessage {
|
|||
ChatMessage(role: 'assistant', content: content);
|
||||
}
|
||||
|
||||
/// Stream options
|
||||
class StreamOptions {
|
||||
final bool includeUsage;
|
||||
|
||||
|
|
@ -194,7 +180,6 @@ class StreamOptions {
|
|||
};
|
||||
}
|
||||
|
||||
/// Chat completion response (non-streaming)
|
||||
class ChatCompletion {
|
||||
final String id;
|
||||
final String object;
|
||||
|
|
@ -224,7 +209,6 @@ class ChatCompletion {
|
|||
);
|
||||
}
|
||||
|
||||
/// Chat completion chunk (streaming)
|
||||
class ChatCompletionChunk {
|
||||
final String id;
|
||||
final String object;
|
||||
|
|
@ -255,7 +239,6 @@ class ChatCompletionChunk {
|
|||
);
|
||||
}
|
||||
|
||||
/// Choice in non-streaming response
|
||||
class Choice {
|
||||
final int index;
|
||||
final ChatMessage message;
|
||||
|
|
@ -274,7 +257,6 @@ class Choice {
|
|||
);
|
||||
}
|
||||
|
||||
/// Choice in streaming response
|
||||
class ChunkChoice {
|
||||
final int index;
|
||||
final Delta? delta;
|
||||
|
|
@ -293,7 +275,6 @@ class ChunkChoice {
|
|||
);
|
||||
}
|
||||
|
||||
/// Delta content in streaming chunks
|
||||
class Delta {
|
||||
final String? role;
|
||||
final String? content;
|
||||
|
|
@ -309,7 +290,6 @@ class Delta {
|
|||
);
|
||||
}
|
||||
|
||||
/// Token usage information
|
||||
class Usage {
|
||||
final int? promptTokens;
|
||||
final int? completionTokens;
|
||||
|
|
@ -334,32 +314,29 @@ class Usage {
|
|||
};
|
||||
}
|
||||
|
||||
/// OpenAI API exception
|
||||
class OpenAIException implements Exception {
|
||||
class OpenRouterException implements Exception {
|
||||
final int statusCode;
|
||||
final String message;
|
||||
|
||||
OpenAIException({
|
||||
OpenRouterException({
|
||||
required this.statusCode,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => 'OpenAIException($statusCode): $message';
|
||||
String toString() => 'OpenRouterException($statusCode): $message';
|
||||
}
|
||||
|
||||
/// Embeddings API
|
||||
class Embeddings {
|
||||
final OpenAI _openai;
|
||||
final OpenRouter _openRouter;
|
||||
|
||||
Embeddings(this._openai);
|
||||
Embeddings(this._openRouter);
|
||||
|
||||
/// Create embeddings for input text
|
||||
Future<EmbeddingResponse> create({
|
||||
required String model,
|
||||
required dynamic input, // String or List<String>
|
||||
required dynamic input,
|
||||
String? user,
|
||||
String? encodingFormat, // 'float' or 'base64'
|
||||
String? encodingFormat,
|
||||
int? dimensions,
|
||||
}) async {
|
||||
final body = {
|
||||
|
|
@ -370,17 +347,17 @@ class Embeddings {
|
|||
if (dimensions != null) 'dimensions': dimensions,
|
||||
};
|
||||
|
||||
final response = await _openai._client.post(
|
||||
Uri.parse('${_openai.baseUrl}/embeddings'),
|
||||
final response = await _openRouter._client.post(
|
||||
Uri.parse('${_openRouter.baseUrl}/embeddings'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ${_openai.apiKey}',
|
||||
'Authorization': 'Bearer ${_openRouter.apiKey}',
|
||||
},
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw OpenAIException(
|
||||
throw OpenRouterException(
|
||||
statusCode: response.statusCode,
|
||||
message: response.body,
|
||||
);
|
||||
|
|
@ -390,7 +367,6 @@ class Embeddings {
|
|||
}
|
||||
}
|
||||
|
||||
/// Embedding response
|
||||
class EmbeddingResponse {
|
||||
final String object;
|
||||
final List<Embedding> data;
|
||||
|
|
@ -406,14 +382,13 @@ class EmbeddingResponse {
|
|||
|
||||
factory EmbeddingResponse.fromJson(Map<String, dynamic> json) =>
|
||||
EmbeddingResponse(
|
||||
object: json['object'],
|
||||
data: (json['data'] as List).map((e) => Embedding.fromJson(e)).toList(),
|
||||
model: json['model'],
|
||||
object: (json['object'] ?? '').toString(),
|
||||
data: (json['data'] as List? ?? []).map((e) => Embedding.fromJson(e)).toList(),
|
||||
model: (json['model'] ?? '').toString(),
|
||||
usage: json['usage'] != null ? Usage.fromJson(json['usage']) : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Individual embedding
|
||||
class Embedding {
|
||||
final String object;
|
||||
final int index;
|
||||
|
|
@ -426,9 +401,10 @@ class Embedding {
|
|||
});
|
||||
|
||||
factory Embedding.fromJson(Map<String, dynamic> json) => Embedding(
|
||||
object: json['object'],
|
||||
index: json['index'],
|
||||
embedding: (json['embedding'] as List).map<double>((e) => (e as num).toDouble()).toList(),
|
||||
);
|
||||
|
||||
}
|
||||
object: (json['object'] ?? '').toString(),
|
||||
index: (json['index'] ?? 0) as int,
|
||||
embedding: (json['embedding'] as List? ?? [])
|
||||
.map<double>((e) => (e as num).toDouble())
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
26
lib/utils/signal_buckets.dart
Normal file
26
lib/utils/signal_buckets.dart
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// bucket 0..1 signal scores (probability, impact) into human labels. the
|
||||
// llm outputs a continuous number but the ui shouldnt pretend that number
|
||||
// means much beyond a rough band — "12.3%" reads as false precision.
|
||||
//
|
||||
// thresholds are deliberately a bit generous at the bottom so that the
|
||||
// failure-fallback path (0.0) lands clearly in NONE.
|
||||
|
||||
enum SignalBucket { none, low, medium, high }
|
||||
|
||||
|
||||
SignalBucket bucketFor(double score) {
|
||||
if (score < 0.15) return SignalBucket.none;
|
||||
if (score < 0.40) return SignalBucket.low;
|
||||
if (score < 0.70) return SignalBucket.medium;
|
||||
return SignalBucket.high;
|
||||
}
|
||||
|
||||
|
||||
String bucketLabel(double score) {
|
||||
switch (bucketFor(score)) {
|
||||
case SignalBucket.none: return "None";
|
||||
case SignalBucket.low: return "Low";
|
||||
case SignalBucket.medium: return "Medium";
|
||||
case SignalBucket.high: return "High";
|
||||
}
|
||||
}
|
||||
235
lib/utils/signal_generator.dart
Normal file
235
lib/utils/signal_generator.dart
Normal 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.0–1.0): likelihood that the underlying event is real / actually happening as reported. Grounded primarily in coverage — number of articles, number of distinct publishers, reputability of those publishers, and how tightly the cluster hangs together semantically (lower avg distance = stronger corroboration). This is NOT the probability that the price moves, and it is NOT conditional on direction. Anchors:
|
||||
* 0.1 — single article, unknown blog, speculative framing, no corroboration
|
||||
* 0.5 — several articles from mid-tier outlets, or mixed/conflicting accounts across publishers
|
||||
* 0.9 — wide coverage: many articles across multiple major wire services or papers of record (Reuters, Bloomberg, AP, WSJ, FT, NYT, etc.) converging on the same core facts
|
||||
|
||||
- impact (number, 0.0–1.0): expected magnitude of the asset's immediate price reaction over roughly the next few trading days, ASSUMING the event is real. Reasoned from what the event actually is, applied to this specific asset. Coverage volume is only a weak prior here — loud news is not the same as impactful news, and long-term significance is not the same as short-term reaction. Anchors (illustrative, oil-related asset):
|
||||
* 0.1 — an OPEC minister makes a vague forward-looking comment about prices
|
||||
* 0.5 — a refinery outage in a secondary producing region; a mid-sized earnings beat
|
||||
* 0.9 — Strait of Hormuz closure; a surprise OPEC+ production cut of material size; a major sanctions announcement hitting the asset's supply or demand
|
||||
|
||||
- direction (string, enum): "positive" | "negative" | "neutral" — expected directional bias of the immediate price reaction for this specific asset. Kept separate from impact so a large negative and a large positive are both high-impact.
|
||||
|
||||
- nature (string, enum): "forecasting" if the cluster is predicting or anticipating a future event, "reactive" if it is reporting on something that already happened.
|
||||
|
||||
- event_summary (string): one neutral sentence describing the event itself. No hedging, no direction words.
|
||||
|
||||
- rationale (string): a short paragraph (2–4 sentences) covering two things, in this order:
|
||||
1. What's actually happening in the cluster — more substantive than event_summary. Pull out the concrete facts: who did what, specific numbers, quoted figures, timelines, named actors. This is the reader learning what the news IS.
|
||||
2. The causal chain from event → price reaction for THIS asset. Why does this move the asset in the chosen direction, and why by the chosen magnitude? Reference the mechanism (supply, demand, competition, margins, guidance, sentiment, regulatory exposure, etc).
|
||||
DO NOT restate probability, the nature label, publisher count, source reputability, corroboration strength, or semantic tightness. All of that is shown in the UI alongside the rationale — repeating it wastes the only place the reader learns anything new. Focus on event substance and causal reasoning, not meta-commentary about the input data.
|
||||
|
||||
Important:
|
||||
* probability is about the event being real, not about price movement.
|
||||
* impact is about magnitude of short-term reaction, not long-term significance.
|
||||
* direction is separate from impact.
|
||||
* Return only the JSON object, no prose, no code fences.
|
||||
""";
|
||||
|
||||
String _buildPrompt(
|
||||
EventCluster cluster, {
|
||||
required String ticker,
|
||||
required String companyName,
|
||||
}) {
|
||||
final articles = cluster.articles;
|
||||
|
||||
final publishers = articles
|
||||
.map((a) => (a.source ?? "").trim())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toSet()
|
||||
.toList();
|
||||
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln("Asset: $companyName ($ticker)");
|
||||
buffer.writeln();
|
||||
|
||||
buffer.writeln("Coverage stats (computed, do not recount):");
|
||||
buffer.writeln(" Articles: ${articles.length}");
|
||||
buffer.writeln(" Distinct publishers: ${publishers.length}");
|
||||
if (publishers.isNotEmpty) {
|
||||
buffer.writeln(" Publishers: ${publishers.join(", ")}");
|
||||
} else {
|
||||
buffer.writeln(" Publishers: (none identified)");
|
||||
}
|
||||
|
||||
final stats = cluster.distanceStats();
|
||||
if (stats.min != null) {
|
||||
buffer.writeln(
|
||||
" Semantic tightness (distance from seed, 0=identical): "
|
||||
"min ${stats.min!.toStringAsFixed(3)}, "
|
||||
"avg ${stats.avg!.toStringAsFixed(3)}, "
|
||||
"max ${stats.max!.toStringAsFixed(3)}",
|
||||
);
|
||||
} else {
|
||||
buffer.writeln(" Semantic tightness: singleton cluster (no neighbours)");
|
||||
}
|
||||
|
||||
buffer.writeln();
|
||||
|
||||
buffer.writeln("Articles:");
|
||||
for (int i = 0; i < articles.length; i++) {
|
||||
final article = articles[i];
|
||||
buffer.writeln("${i + 1}. Title: ${article.title}");
|
||||
if ((article.source ?? "").trim().isNotEmpty) {
|
||||
buffer.writeln(" Publisher: ${article.source}");
|
||||
}
|
||||
|
||||
final desc = article.description.trim();
|
||||
if (desc.isNotEmpty) {
|
||||
buffer.writeln(" Description: $desc");
|
||||
}
|
||||
|
||||
final body = _clipContent(article.content);
|
||||
if (body.isNotEmpty) {
|
||||
buffer.writeln(" Content: $body");
|
||||
}
|
||||
|
||||
buffer.writeln(" Link: ${article.link}");
|
||||
}
|
||||
|
||||
buffer.writeln();
|
||||
buffer.writeln("Return a single JSON object with keys: event_summary, direction, nature, probability, impact, rationale.");
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
|
||||
// trim + truncate article body to the char cap. returns empty string if
|
||||
// theres nothing useful to include.
|
||||
String _clipContent(String content) {
|
||||
final trimmed = content.trim();
|
||||
if (trimmed.isEmpty) return "";
|
||||
|
||||
if (trimmed.length <= _kContentCharCap) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return "${trimmed.substring(0, _kContentCharCap)}...";
|
||||
}
|
||||
|
||||
String _extractJson(String content) {
|
||||
final start = content.indexOf("{");
|
||||
final end = content.lastIndexOf("}");
|
||||
|
||||
if (start == -1 || end == -1 || end < start) {
|
||||
throw const FormatException("No JSON object found in model response.");
|
||||
}
|
||||
|
||||
return content.substring(start, end + 1);
|
||||
}
|
||||
|
||||
String _fallbackSummary(List<FeedItem> articles) {
|
||||
if (articles.isEmpty) {
|
||||
return "Unknown event";
|
||||
}
|
||||
|
||||
return articles.first.title;
|
||||
}
|
||||
|
||||
// deterministic id from the sorted link set — same cluster re-run produces
|
||||
// the same id, which is handy for dedupe later.
|
||||
String _eventIdFor(List<FeedItem> articles) {
|
||||
final links = articles.map((a) => a.link).toList()..sort();
|
||||
final joined = links.join("|");
|
||||
final h = joined.hashCode & 0x7FFFFFFF;
|
||||
return "evt_${h.toRadixString(36)}";
|
||||
}
|
||||
}
|
||||
251
lib/widgets/app_shell.dart
Normal file
251
lib/widgets/app_shell.dart
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
|
||||
Color contentBgColor(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final h = HSLColor.fromColor(theme.colorScheme.border).hue;
|
||||
final dark = theme.brightness == Brightness.dark;
|
||||
return dark
|
||||
? HSLColor.fromAHSL(1, h, 0.35, 0.13).toColor()
|
||||
: HSLColor.fromAHSL(1, h, 0.30, 0.88).toColor();
|
||||
}
|
||||
|
||||
|
||||
// The main shell. replaces Scaffold in all pages.
|
||||
class AugorShell extends StatelessWidget {
|
||||
const AugorShell({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.titleTag,
|
||||
this.headerLeading = const [],
|
||||
this.headerTrailing = const [],
|
||||
this.statusLeft,
|
||||
this.statusRight,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final String titleTag;
|
||||
final List<Widget> headerLeading;
|
||||
final List<Widget> headerTrailing;
|
||||
final Widget? statusLeft;
|
||||
final Widget? statusRight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final monoStyle = GoogleFonts.ibmPlexMono(
|
||||
fontSize: 11,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
|
||||
// ── header ──────────────────────────────────────────────────────────
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.background,
|
||||
border: Border(bottom: BorderSide(color: theme.colorScheme.border, width: 1)),
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12, top: 4, bottom: 4),
|
||||
child: Row(
|
||||
children: headerLeading,
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
...headerTrailing,
|
||||
|
||||
// title tag box
|
||||
Container(
|
||||
color: theme.colorScheme.border,
|
||||
constraints: const BoxConstraints(minWidth: 100),
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: RichText(
|
||||
text: TextSpan(text: titleTag.toUpperCase(), style: monoStyle),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 64), // mac traffic lights gap
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── content ─────────────────────────────────────────────────────────
|
||||
Expanded(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ColoredBox(
|
||||
color: contentBgColor(context),
|
||||
child: child,
|
||||
),
|
||||
const Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: CustomPaint(painter: _InsetShadowPainter()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── footer / status bar ─────────────────────────────────────────────
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.background,
|
||||
border: Border(top: BorderSide(color: theme.colorScheme.border, width: 1)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
Expanded(
|
||||
child: statusLeft ?? const SizedBox.shrink(),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: _NavItems(),
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: statusRight ?? const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// shared footer nav — home and settings links
|
||||
class _NavItems extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final location = GoRouterState.of(context).uri.toString();
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final monoStyle = GoogleFonts.ibmPlexMono(
|
||||
fontSize: 11,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w600,
|
||||
);
|
||||
|
||||
Widget navItem(String label, String path) {
|
||||
final active = location == path || (path == '/' && location == '/');
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => GoRouter.of(context).go(path),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? theme.colorScheme.border : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
text: label,
|
||||
style: monoStyle.copyWith(
|
||||
color: active
|
||||
? theme.colorScheme.foreground
|
||||
: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
navItem('Home', '/'),
|
||||
const SizedBox(width: 2),
|
||||
navItem('Settings', '/settings'),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// footer status text helper — use this for statusLeft / statusRight
|
||||
class StatusText extends StatelessWidget {
|
||||
const StatusText(this.text, {super.key});
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return RichText(
|
||||
text: TextSpan(
|
||||
text: text,
|
||||
style: GoogleFonts.ibmPlexMono(
|
||||
fontSize: 11,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _InsetShadowPainter extends CustomPainter {
|
||||
const _InsetShadowPainter();
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
const blur = 12.0;
|
||||
final rect = Offset.zero & size;
|
||||
|
||||
canvas.save();
|
||||
canvas.clipRect(rect);
|
||||
|
||||
final innerRect = rect.deflate(24);
|
||||
final ringClip = Path()
|
||||
..addRect(rect)
|
||||
..addRect(innerRect)
|
||||
..fillType = PathFillType.evenOdd;
|
||||
canvas.clipPath(ringClip);
|
||||
|
||||
final path = Path()
|
||||
..addRect(rect.inflate(blur * 2))
|
||||
..addRect(rect)
|
||||
..fillType = PathFillType.evenOdd;
|
||||
|
||||
final paint = Paint()
|
||||
..color = const Color(0x55000000)
|
||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, blur);
|
||||
|
||||
canvas.drawPath(path, paint);
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter old) => false;
|
||||
}
|
||||
318
lib/widgets/event_signal_card.dart
Normal file
318
lib/widgets/event_signal_card.dart
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
import 'package:capstone_project/models/event_signal.dart';
|
||||
import 'package:capstone_project/utils/signal_buckets.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
|
||||
class EventSignalCard extends StatelessWidget {
|
||||
final EventSignal signal;
|
||||
|
||||
const EventSignalCard({super.key, required this.signal});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final signalColor = switch (signal.direction) {
|
||||
'positive' => const Color(0xFF16a34a),
|
||||
'negative' => const Color(0xFFdc2626),
|
||||
_ => theme.colorScheme.mutedForeground,
|
||||
};
|
||||
|
||||
final signalBg = switch (signal.direction) {
|
||||
'positive' => const Color(0xFF16a34a).withValues(alpha: 0.12),
|
||||
'negative' => const Color(0xFFdc2626).withValues(alpha: 0.12),
|
||||
_ => theme.colorScheme.border.withValues(alpha: 0.4),
|
||||
};
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.background.withValues(alpha: 0.8),
|
||||
border: Border.all(color: theme.colorScheme.border, width: 1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.12),
|
||||
blurRadius: 4,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
// header row
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: theme.colorScheme.border, width: 1)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(signal.eventSummary).semiBold,
|
||||
const Gap(4),
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
text: _formatDate(signal.createdAt),
|
||||
style: GoogleFonts.ibmPlexMono(
|
||||
fontSize: 10,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Gap(12),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.border.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: theme.colorScheme.border, width: 1),
|
||||
),
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
text: signal.nature,
|
||||
style: GoogleFonts.ibmPlexMono(
|
||||
fontSize: 10,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: signalBg,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: signalColor.withValues(alpha: 0.3), width: 1),
|
||||
),
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
text: signal.direction,
|
||||
style: GoogleFonts.ibmPlexMono(
|
||||
fontSize: 10,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: signalColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// probability + impact as bucketed labels. the raw percentages
|
||||
// pretended to more precision than the llm actually gives us.
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 14, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
_BucketRow(
|
||||
label: 'CREDIBILITY',
|
||||
bucket: bucketLabel(signal.probability),
|
||||
color: signalColor,
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
_BucketRow(
|
||||
label: 'IMPACT',
|
||||
bucket: bucketLabel(signal.impact),
|
||||
color: signalColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 14, 0),
|
||||
child: Text(signal.rationale).small,
|
||||
),
|
||||
|
||||
// articles
|
||||
if (signal.articles.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 0),
|
||||
child: Divider(color: Theme.of(context).colorScheme.border, height: 1),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 8, 14, 0),
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
text: 'SOURCES ${signal.articles.length}',
|
||||
style: GoogleFonts.ibmPlexMono(
|
||||
fontSize: 10,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final article in signal.articles)
|
||||
_ArticleLink(article: article),
|
||||
],
|
||||
|
||||
const SizedBox(height: 14),
|
||||
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ArticleLink extends StatefulWidget {
|
||||
final dynamic article; // FeedItem
|
||||
|
||||
const _ArticleLink({required this.article});
|
||||
|
||||
@override
|
||||
State<_ArticleLink> createState() => _ArticleLinkState();
|
||||
}
|
||||
|
||||
class _ArticleLinkState extends State<_ArticleLink> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final hasLink = widget.article.link.isNotEmpty;
|
||||
|
||||
return MouseRegion(
|
||||
cursor: hasLink ? SystemMouseCursors.click : MouseCursor.defer,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: hasLink
|
||||
? () => launchUrl(Uri.parse(widget.article.link), mode: LaunchMode.externalApplication)
|
||||
: null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 6, 14, 0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Icon(
|
||||
LucideIcons.externalLink,
|
||||
size: 10,
|
||||
color: _hovered
|
||||
? theme.colorScheme.foreground
|
||||
: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.article.title,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: _hovered
|
||||
? theme.colorScheme.foreground
|
||||
: theme.colorScheme.mutedForeground,
|
||||
decoration: _hovered ? TextDecoration.underline : TextDecoration.none,
|
||||
decorationColor: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
if (widget.article.source != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
text: widget.article.source,
|
||||
style: GoogleFonts.ibmPlexMono(
|
||||
fontSize: 9,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.mutedForeground.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// small two-piece label: "LABEL Value". kept compact so we can stack
|
||||
// credibility and impact on one line without the card feeling cramped.
|
||||
class _BucketRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String bucket;
|
||||
final Color color;
|
||||
|
||||
const _BucketRow({
|
||||
required this.label,
|
||||
required this.bucket,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
text: label,
|
||||
style: GoogleFonts.ibmPlexMono(
|
||||
fontSize: 10,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
text: bucket.toUpperCase(),
|
||||
style: GoogleFonts.ibmPlexMono(
|
||||
fontSize: 10,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
String _formatDate(DateTime dt) {
|
||||
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
final h = dt.hour.toString().padLeft(2, '0');
|
||||
final m = dt.minute.toString().padLeft(2, '0');
|
||||
return '${months[dt.month - 1]} ${dt.day}, ${dt.year} $h:$m';
|
||||
}
|
||||
|
|
@ -1,53 +1 @@
|
|||
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class ProjNavBar extends StatelessWidget {
|
||||
|
||||
|
||||
static final Map<String, int> _pageIndex = {
|
||||
"home": 0,
|
||||
"settings": 1
|
||||
};
|
||||
|
||||
late final int selectedIndex;
|
||||
|
||||
ProjNavBar({super.key, String currentPage = "home"}) {;
|
||||
selectedIndex = _pageIndex[currentPage] ?? 0;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// TODO: implement build
|
||||
return NavigationBar(
|
||||
index: selectedIndex,
|
||||
onSelected: (index) {
|
||||
if (index == 0) {
|
||||
GoRouter.of(context).go("/");
|
||||
} else if (index == 1) {
|
||||
GoRouter.of(context).go("/settings");
|
||||
}
|
||||
},
|
||||
children: [
|
||||
NavigationItem(
|
||||
label: Text(
|
||||
"Home"
|
||||
),
|
||||
child: Icon(
|
||||
LucideIcons.house
|
||||
),
|
||||
),
|
||||
NavigationItem(
|
||||
label: Text(
|
||||
"Settings"
|
||||
),
|
||||
child: Icon(
|
||||
LucideIcons.settings
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
// deprecated — navigation is now handled by AugorShell footer
|
||||
|
|
|
|||
239
lib/widgets/panel_layout.dart
Normal file
239
lib/widgets/panel_layout.dart
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import "dart:math" as math;
|
||||
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
|
||||
class PanelField {
|
||||
const PanelField({
|
||||
required this.section,
|
||||
required this.label,
|
||||
required this.child,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
final String section;
|
||||
final Widget label;
|
||||
final Widget child;
|
||||
final bool enabled;
|
||||
}
|
||||
|
||||
// auto groups fields by section string
|
||||
class PanelList extends StatelessWidget {
|
||||
const PanelList({super.key, required this.fields});
|
||||
|
||||
final List<PanelField> fields;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final Map<String, List<PanelField>> grouped = {};
|
||||
for (final f in fields) {
|
||||
grouped.putIfAbsent(f.section, () => []).add(f);
|
||||
}
|
||||
|
||||
final entries = grouped.entries.toList();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (int i = 0; i < entries.length; i++) ...[
|
||||
PanelSection(
|
||||
title: entries[i].key,
|
||||
children: entries[i].value
|
||||
.map((f) => PanelRow(label: f.label, child: f.child, enabled: f.enabled))
|
||||
.toList(),
|
||||
),
|
||||
|
||||
if (i < entries.length - 1)
|
||||
Divider(color: theme.colorScheme.border, height: 1),
|
||||
],
|
||||
Divider(color: theme.colorScheme.border, height: 1),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class PanelSection extends StatefulWidget {
|
||||
const PanelSection({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
State<PanelSection> createState() => _PanelSectionState();
|
||||
}
|
||||
|
||||
class _PanelSectionState extends State<PanelSection> {
|
||||
bool _expanded = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: ColoredBox(
|
||||
color: theme.colorScheme.secondary,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
AnimatedRotation(
|
||||
turns: _expanded ? 0.0 : -0.25,
|
||||
duration: const Duration(milliseconds: 120),
|
||||
child: Icon(LucideIcons.chevronDown, size: 12, color: theme.colorScheme.mutedForeground),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
widget.title,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.foreground,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Divider(color: theme.colorScheme.border, height: 1),
|
||||
|
||||
if (_expanded)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (int i = 0; i < widget.children.length; i++) ...[
|
||||
widget.children[i],
|
||||
if (i < widget.children.length - 1)
|
||||
Divider(color: theme.colorScheme.border, height: 1),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class PanelRow extends StatelessWidget {
|
||||
const PanelRow({super.key, required this.label, required this.child, this.enabled = true});
|
||||
|
||||
final Widget label;
|
||||
final Widget child;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 38),
|
||||
child: IntrinsicHeight(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: DefaultTextStyle.merge(
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
decoration: !enabled ? TextDecoration.lineThrough : null,
|
||||
decorationThickness: !enabled ? 3 : null,
|
||||
),
|
||||
child: label,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
VerticalDivider(color: theme.colorScheme.border, width: 1),
|
||||
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
if (!enabled)
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: () {},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: CustomPaint(
|
||||
painter: _DisabledStripePainter(theme.colorScheme.border),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _DisabledStripePainter extends CustomPainter {
|
||||
const _DisabledStripePainter(this.color);
|
||||
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = color.withValues(alpha: 1)
|
||||
..strokeWidth = 1.0
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
const spacing = 4.0;
|
||||
final diag = math.sqrt(size.width * size.width + size.height * size.height);
|
||||
final count = (diag / spacing).ceil() + 2;
|
||||
|
||||
canvas.save();
|
||||
canvas.clipRect(Offset.zero & size);
|
||||
canvas.drawRect(Rect.fromLTWH(1, 1, size.width - 3, size.height - 2), paint);
|
||||
|
||||
canvas.translate(0, size.height);
|
||||
canvas.rotate(-math.pi / 4);
|
||||
|
||||
for (int i = -count; i <= count; i++) {
|
||||
final x = i * spacing;
|
||||
canvas.drawLine(Offset(x, -diag), Offset(x, diag), paint);
|
||||
}
|
||||
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_DisabledStripePainter old) => old.color != color;
|
||||
}
|
||||
214
lib/widgets/signal_probability_chart.dart
Normal file
214
lib/widgets/signal_probability_chart.dart
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import "dart:math";
|
||||
|
||||
import "package:capstone_project/models/event_signal.dart";
|
||||
import "package:capstone_project/utils/signal_buckets.dart";
|
||||
import "package:fl_chart/fl_chart.dart";
|
||||
import "package:google_fonts/google_fonts.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
|
||||
const Color _growthColor = Color(0xFF16a34a);
|
||||
const Color _declineColor = Color(0xFFdc2626);
|
||||
const Color _neutralColor = Color(0xFF888888);
|
||||
|
||||
// one day in milliseconds — used to convert between DateTime and the chart's x axis
|
||||
const double _msPerDay = 1000 * 60 * 60 * 24;
|
||||
|
||||
|
||||
// Scatter of signals: X is the signal date, Y is the impact, color is direction,
|
||||
// opacity scales with probability (low-credibility signals fade into the back).
|
||||
// No line connecting them — signals are discrete events, not a time series.
|
||||
class SignalProbabilityChart extends StatelessWidget {
|
||||
final List<EventSignal> signals;
|
||||
final double height;
|
||||
final bool compact;
|
||||
|
||||
const SignalProbabilityChart({
|
||||
super.key,
|
||||
required this.signals,
|
||||
this.height = 200,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
if (signals.isEmpty) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.secondary,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: theme.colorScheme.border, width: 1),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text("No signal history yet").muted.small,
|
||||
);
|
||||
}
|
||||
|
||||
// sort so index ordering lines up with the spots list (used for tooltip lookup)
|
||||
final sorted = [...signals]..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
|
||||
final xs = sorted.map((s) => _toDays(s.createdAt)).toList();
|
||||
final rawMinX = xs.reduce(min);
|
||||
final rawMaxX = xs.reduce(max);
|
||||
|
||||
// keep a minimum visual span so a single-date cluster doesn't collapse the axis
|
||||
final span = (rawMaxX - rawMinX) < 1.0 ? 1.0 : (rawMaxX - rawMinX);
|
||||
final xPad = span * 0.06;
|
||||
final minX = rawMinX - xPad;
|
||||
final maxX = rawMaxX + xPad;
|
||||
|
||||
final spots = <ScatterSpot>[];
|
||||
for (final s in sorted) {
|
||||
final base = _directionColor(s.direction);
|
||||
// probability drives the alpha: 0.0 → 0.30, 1.0 → 0.95
|
||||
final alpha = 0.30 + (s.probability.clamp(0.0, 1.0) * 0.65);
|
||||
spots.add(
|
||||
ScatterSpot(
|
||||
_toDays(s.createdAt),
|
||||
s.impact,
|
||||
dotPainter: FlDotCirclePainter(
|
||||
radius: 5,
|
||||
color: base.withValues(alpha: alpha),
|
||||
strokeWidth: 1,
|
||||
strokeColor: theme.colorScheme.background,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final labelStyle = GoogleFonts.ibmPlexMono(
|
||||
fontSize: 10,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
);
|
||||
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: height,
|
||||
child: ScatterChart(
|
||||
ScatterChartData(
|
||||
minX: minX,
|
||||
maxX: maxX,
|
||||
minY: 0,
|
||||
maxY: 1,
|
||||
scatterSpots: spots,
|
||||
|
||||
gridData: FlGridData(
|
||||
show: !compact,
|
||||
drawVerticalLine: false,
|
||||
horizontalInterval: 0.25,
|
||||
getDrawingHorizontalLine: (_) => FlLine(
|
||||
color: theme.colorScheme.border,
|
||||
strokeWidth: 1,
|
||||
),
|
||||
),
|
||||
|
||||
borderData: FlBorderData(
|
||||
show: !compact,
|
||||
border: Border.all(color: theme.colorScheme.border, width: 1),
|
||||
),
|
||||
|
||||
titlesData: FlTitlesData(
|
||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: !compact,
|
||||
reservedSize: 22,
|
||||
interval: _xTickInterval(minX, maxX),
|
||||
getTitlesWidget: (value, meta) {
|
||||
final d = DateTime.fromMillisecondsSinceEpoch(
|
||||
(value * _msPerDay).round(),
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: RichText(text: TextSpan(text: _shortDate(d), style: labelStyle)),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: !compact,
|
||||
reservedSize: 42,
|
||||
interval: 0.25,
|
||||
getTitlesWidget: (value, meta) => RichText(
|
||||
text: TextSpan(
|
||||
text: "${(value * 100).round()}%",
|
||||
style: labelStyle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
scatterTouchData: compact
|
||||
? ScatterTouchData(enabled: false)
|
||||
: ScatterTouchData(
|
||||
enabled: true,
|
||||
touchTooltipData: ScatterTouchTooltipData(
|
||||
getTooltipColor: (_) => theme.colorScheme.background,
|
||||
tooltipBorder: BorderSide(color: theme.colorScheme.border, width: 1),
|
||||
tooltipPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
maxContentWidth: 220,
|
||||
getTooltipItems: (ScatterSpot touched) {
|
||||
// match back to the source signal by x+y — ScatterSpot equality
|
||||
// isn't guaranteed to line up with our original instances
|
||||
final idx = sorted.indexWhere((s) =>
|
||||
_toDays(s.createdAt) == touched.x && s.impact == touched.y);
|
||||
if (idx < 0) return null;
|
||||
final s = sorted[idx];
|
||||
|
||||
return ScatterTooltipItem(
|
||||
"${_shortDate(s.createdAt)}\n"
|
||||
"impact ${(s.impact * 100).toStringAsFixed(0)}% · ${s.direction}\n"
|
||||
"probability ${(s.probability * 100).toStringAsFixed(0)}%",
|
||||
textStyle: GoogleFonts.ibmPlexMono(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.foreground,
|
||||
),
|
||||
textAlign: TextAlign.left,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
double _toDays(DateTime d) => d.millisecondsSinceEpoch / _msPerDay;
|
||||
|
||||
|
||||
Color _directionColor(String direction) => switch (direction) {
|
||||
"positive" => _growthColor,
|
||||
"negative" => _declineColor,
|
||||
_ => _neutralColor,
|
||||
};
|
||||
|
||||
|
||||
// pick a sensible x-axis tick interval (in days) based on the visible range
|
||||
double _xTickInterval(double minX, double maxX) {
|
||||
final days = (maxX - minX).abs();
|
||||
if (days <= 7) return 1;
|
||||
if (days <= 30) return 7;
|
||||
if (days <= 90) return 14;
|
||||
if (days <= 365) return 30;
|
||||
return (days / 6).ceilToDouble();
|
||||
}
|
||||
|
||||
|
||||
String _shortDate(DateTime d) {
|
||||
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
||||
return "${months[d.month - 1]} ${d.day}";
|
||||
}
|
||||
258
lib/widgets/stock_price_chart.dart
Normal file
258
lib/widgets/stock_price_chart.dart
Normal 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}';
|
||||
}
|
||||
|
|
@ -6,14 +6,10 @@
|
|||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <irondash_engine_context/irondash_engine_context_plugin.h>
|
||||
#include <super_native_extensions/super_native_extensions_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) irondash_engine_context_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "IrondashEngineContextPlugin");
|
||||
irondash_engine_context_plugin_register_with_registrar(irondash_engine_context_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);
|
||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
irondash_engine_context
|
||||
super_native_extensions
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
|
|
|||
|
|
@ -5,18 +5,14 @@
|
|||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import device_info_plus
|
||||
import file_picker
|
||||
import irondash_engine_context
|
||||
import path_provider_foundation
|
||||
import shared_preferences_foundation
|
||||
import super_native_extensions
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||
IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
platform :osx, '10.15'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
project 'Runner', {
|
||||
|
|
|
|||
16
macos/Podfile.lock
Normal file
16
macos/Podfile.lock
Normal 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
|
||||
|
|
@ -27,6 +27,7 @@
|
|||
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
|
||||
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
|
||||
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 */; };
|
||||
98FD69AA4561E2418368B2C0 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1B93A602E978951E58D988DB /* Pods_RunnerTests.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
|
@ -84,6 +85,7 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
|
@ -103,6 +105,7 @@
|
|||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||
93FF9985C779BC8BA2DCAFDE /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
|
@ -164,6 +167,7 @@
|
|||
33CEB47122A05771004F2AC0 /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
|
||||
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
|
||||
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
|
||||
|
|
@ -240,7 +244,6 @@
|
|||
33CC10EB2044A3C60003C045 /* Resources */,
|
||||
33CC110E2044A8840003C045 /* Bundle Framework */,
|
||||
3399D490228B24CF009A79C7 /* ShellScript */,
|
||||
67D0769C6B58A1F69B052AF5 /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
|
|
@ -248,6 +251,9 @@
|
|||
33CC11202044C79F0003C045 /* PBXTargetDependency */,
|
||||
);
|
||||
name = Runner;
|
||||
packageProductDependencies = (
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||
);
|
||||
productName = Runner;
|
||||
productReference = 33CC10ED2044A3C60003C045 /* capstone_project.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
|
|
@ -292,6 +298,9 @@
|
|||
Base,
|
||||
);
|
||||
mainGroup = 33CC10E42044A3C60003C045;
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
|
||||
);
|
||||
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
|
|
@ -383,23 +392,6 @@
|
|||
shellPath = /bin/sh;
|
||||
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 */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
|
|
@ -796,6 +788,20 @@
|
|||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* 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 */;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,24 @@
|
|||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<PreActions>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "Run Prepare Flutter Framework Script"
|
||||
scriptText = ""$FLUTTER_ROOT"/packages/flutter_tools/bin/macos_assemble.sh prepare ">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||
BuildableName = "capstone_project.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
</PreActions>
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
|
|
|
|||
97
mvp_watchlist.md
Normal file
97
mvp_watchlist.md
Normal 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
59
progress.md
Normal 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.
|
||||
241
pubspec.lock
241
pubspec.lock
|
|
@ -1,14 +1,14 @@
|
|||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
archive:
|
||||
animation_kit:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
|
||||
name: animation_kit
|
||||
sha256: d9b0944b3ee02fae3fedbc6cb04d9a9ea26ad1d29f3261e0b55443b1e0bfba63
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.7"
|
||||
version: "0.0.2"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -37,10 +37,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
version: "1.4.1"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -69,10 +69,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: country_flags
|
||||
sha256: "78a7bf8aabd7ae1a90087f0c517471ac9ebfe07addc652692f58da0f0f833196"
|
||||
sha256: f022d18337f3861f1f4e319b936cb53920de9259f38cb09e169eace9942e2b79
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.3.0"
|
||||
version: "4.1.2"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -85,10 +85,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.6"
|
||||
version: "3.0.7"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -101,10 +101,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: data_widget
|
||||
sha256: "95388df890189014f702b7e93f9de6bcf7d45143a99f6288f31899f10be441ba"
|
||||
sha256: "4947aae3c50635496d56f94ad18de98e19015c5ebf01abee0f39a2c098c7021a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.2"
|
||||
version: "0.0.3"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -113,22 +113,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -137,6 +121,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
equatable:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: equatable
|
||||
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.8"
|
||||
expressions:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -177,14 +169,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.3.7"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
fl_chart:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
name: fl_chart
|
||||
sha256: "74959b99b92b9eebeed1a4049426fd67c4abc3c5a0f4d12e2877097d6a11ae08"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
version: "0.69.2"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
|
|
@ -198,6 +190,11 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_localizations:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -232,6 +229,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -248,30 +253,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
image:
|
||||
intl:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
|
||||
name: intl
|
||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.4"
|
||||
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"
|
||||
version: "0.20.2"
|
||||
jovial_misc:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -332,26 +321,26 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.17"
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
version: "1.18.0"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -432,14 +421,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -456,14 +437,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: posix
|
||||
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.3"
|
||||
provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -492,10 +465,10 @@ packages:
|
|||
dependency: "direct main"
|
||||
description:
|
||||
name: shadcn_flutter
|
||||
sha256: af83de199b7c3a965ab24e293cfcafe2764c12b7f911f5b1a427c332029262d9
|
||||
sha256: b04e2f790e182007d02b78234c647df393f2ea95b39d8da88d7cbdaed56f7701
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.44"
|
||||
version: "0.0.52"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -573,14 +546,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.1"
|
||||
sprintf:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sprintf
|
||||
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.0"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -605,30 +570,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -641,10 +582,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
version: "0.7.10"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -661,14 +602,70 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.1"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: uuid
|
||||
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff
|
||||
name: url_launcher
|
||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||
url: "https://pub.dev"
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -701,14 +698,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -726,5 +715,5 @@ packages:
|
|||
source: hosted
|
||||
version: "6.6.1"
|
||||
sdks:
|
||||
dart: ">=3.10.0-75.1.beta <4.0.0"
|
||||
flutter: ">=3.35.1"
|
||||
dart: ">=3.10.0 <4.0.0"
|
||||
flutter: ">=3.38.0"
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ dependencies:
|
|||
flutter:
|
||||
sdk: flutter
|
||||
go_router: ^16.2.4
|
||||
shadcn_flutter: ^0.0.44
|
||||
shadcn_flutter: ^0.0.52
|
||||
provider: ^6.1.5+1
|
||||
shared_preferences: ^2.5.4
|
||||
uid: ^0.1.1
|
||||
|
|
@ -39,6 +39,9 @@ dependencies:
|
|||
file_picker: ^10.3.7
|
||||
path_provider: ^2.1.5
|
||||
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.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
|
|
|
|||
|
|
@ -1,30 +1,22 @@
|
|||
// This is a basic Flutter widget test.
|
||||
//
|
||||
// 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:capstone_project/pages/home.dart';
|
||||
import 'package:capstone_project/providers/watchlist.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:capstone_project/main.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const MyApp());
|
||||
testWidgets('home page shows empty watchlist state and add action', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => WatchlistProvider(),
|
||||
child: const ShadcnApp(
|
||||
home: HomePage(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Verify that our counter starts at 0.
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('1'), findsNothing);
|
||||
|
||||
// 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);
|
||||
expect(find.text('Augor'), findsOneWidget);
|
||||
expect(find.text('Your watchlist is empty'), findsOneWidget);
|
||||
expect(find.text('Add stock'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,12 +6,9 @@
|
|||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <irondash_engine_context/irondash_engine_context_plugin_c_api.h>
|
||||
#include <super_native_extensions/super_native_extensions_plugin_c_api.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
IrondashEngineContextPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("IrondashEngineContextPluginCApi"));
|
||||
SuperNativeExtensionsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("SuperNativeExtensionsPluginCApi"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
irondash_engine_context
|
||||
super_native_extensions
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue