Refactor project structure and enhance stock watchlist functionality
This commit is contained in:
+338
-115
@@ -1,136 +1,359 @@
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:capstone_project/providers/settings.dart';
|
||||
import 'package:capstone_project/utils/agrigator.dart';
|
||||
import 'package:capstone_project/widgets/navbar.dart';
|
||||
import 'package:capstone_project/data/stock_list.dart';
|
||||
import 'package:capstone_project/models/watched_stock.dart';
|
||||
import 'package:capstone_project/providers/watchlist.dart';
|
||||
import 'package:capstone_project/services/stock_price_service.dart';
|
||||
import 'package:capstone_project/widgets/app_shell.dart';
|
||||
import 'package:capstone_project/widgets/stock_price_chart.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
|
||||
class HomePage extends StatelessWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
static GoRoute route = GoRoute(
|
||||
path: "/",
|
||||
builder: (context, state) => HomePage()
|
||||
builder: (context, state) => const HomePage(),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// TODO: implement build
|
||||
return Scaffold(
|
||||
headers: [
|
||||
AppBar()
|
||||
],
|
||||
footers: [
|
||||
ProjNavBar(
|
||||
currentPage: "home",
|
||||
)
|
||||
],
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
return Consumer<WatchlistProvider>(
|
||||
builder: (context, watchlist, _) {
|
||||
final stocks = watchlist.stocks;
|
||||
|
||||
Text(
|
||||
"Nothing here is final yet!",
|
||||
).h1,
|
||||
|
||||
Gap(16),
|
||||
|
||||
Button.primary(
|
||||
onPressed: () async {
|
||||
print("Aggregating feeds...");
|
||||
SettingsProvider settings = SettingsProvider.of(context);
|
||||
|
||||
// Fetch all feeds
|
||||
List<Uri> feedUris = settings.feeds.map((feed) => Uri.parse(feed.url)).toList();
|
||||
List<FeedItem> aggregatedItems = await fetchFeeds(feedUris);
|
||||
|
||||
// Save it to a file before generating embeddings
|
||||
String agregatedJson = JsonEncoder.withIndent(' ').convert(aggregatedItems);
|
||||
File agregatedJsonFile = File("${settings.applicationStorageLocation}/aggregated_feed.json");
|
||||
await agregatedJsonFile.writeAsString(agregatedJson);
|
||||
print("Aggregated feed saved to ${agregatedJsonFile.path}");
|
||||
|
||||
// Generate embeddings for all items
|
||||
print("Generating embeddings for ${aggregatedItems.length} items...");
|
||||
List<FeedItem> enrichedItems = [...aggregatedItems];
|
||||
await generateEmbeddings(enrichedItems, settings.openAIApiKey);
|
||||
|
||||
// Save it to a file in the application storage location
|
||||
String enrichedJson = JsonEncoder.withIndent(' ').convert(enrichedItems);
|
||||
final file = File("${settings.applicationStorageLocation}/enriched_aggregated_feed.json");
|
||||
await file.writeAsString(enrichedJson);
|
||||
print("Enriched aggregated feed saved to ${file.path}");
|
||||
|
||||
// Filter out irrelevant items
|
||||
print("Filtering relevant items...");
|
||||
await generateKeywordEmbeddings(settings.openAIApiKey);
|
||||
List<FeedItem> relevantItems = [...aggregatedItems]..removeWhere((item) => !isFeedItemRelevant(item));
|
||||
String relevantJson = JsonEncoder.withIndent(' ').convert(relevantItems);
|
||||
final fileRelevant = File("${settings.applicationStorageLocation}/relevant_aggregated_feed.json");
|
||||
await fileRelevant.writeAsString(relevantJson);
|
||||
print("Cut down from ${aggregatedItems.length} to ${relevantItems.length} relevant items.");
|
||||
print("Relevant aggregated feed saved to ${fileRelevant.path}");
|
||||
|
||||
// For human readability, save a version without embeddings
|
||||
List<FeedItem> readableItems = relevantItems.map((item) {
|
||||
return FeedItem(
|
||||
title: item.title,
|
||||
link: item.link,
|
||||
description: item.description,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
String readableJson = JsonEncoder.withIndent(' ').convert(readableItems);
|
||||
final fileReadable = File("${settings.applicationStorageLocation}/readable_relevant_aggregated_feed.json");
|
||||
await fileReadable.writeAsString(readableJson);
|
||||
print("Readable relevant aggregated feed saved to ${fileReadable.path}");
|
||||
|
||||
// Group by event
|
||||
print("Grouping feed items by event...");
|
||||
List<List<FeedItem>> groupedItems = groupFeedItemsByEvent(relevantItems);
|
||||
List<List<Map<String, dynamic>>> groupedItemsJson = groupedItems.map((group) {
|
||||
return group.map((item) => item.toJson()).toList();
|
||||
}).toList();
|
||||
String groupedJson = JsonEncoder.withIndent(' ').convert(groupedItemsJson);
|
||||
final fileGrouped = File("${settings.applicationStorageLocation}/grouped_relevant_aggregated_feed.json");
|
||||
await fileGrouped.writeAsString(groupedJson);
|
||||
print("Grouped relevant aggregated feed saved to ${fileGrouped.path}");
|
||||
|
||||
// For human readability, save a version without embeddings
|
||||
List<List<FeedItem>> readableGroupedItems = groupedItems.map((group) {
|
||||
return group.map((item) {
|
||||
return FeedItem(
|
||||
title: item.title,
|
||||
link: item.link,
|
||||
description: item.description,
|
||||
);
|
||||
}).toList();
|
||||
}).toList();
|
||||
// Sort groups by size descending
|
||||
readableGroupedItems.sort((a, b) => b.length.compareTo(a.length));
|
||||
List<List<Map<String, dynamic>>> readableGroupedItemsJson = readableGroupedItems.map((group) {
|
||||
return group.map((item) => item.toJson()).toList();
|
||||
}).toList();
|
||||
String readableGroupedJson = JsonEncoder.withIndent(' ').convert(readableGroupedItemsJson);
|
||||
final fileReadableGrouped = File("${settings.applicationStorageLocation}/readable_grouped_relevant_aggregated_feed.json");
|
||||
await fileReadableGrouped.writeAsString(readableGroupedJson);
|
||||
print("Readable grouped relevant aggregated feed saved to ${fileReadableGrouped.path}");
|
||||
|
||||
},
|
||||
child: Text(
|
||||
"Aggregate your feeds"
|
||||
return AugorShell(
|
||||
titleTag: 'Watchlist',
|
||||
headerLeading: [
|
||||
_AppName(),
|
||||
],
|
||||
headerTrailing: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Button.primary(
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (context) => const AddStockDialog(),
|
||||
),
|
||||
child: const Text('Add stock'),
|
||||
),
|
||||
)
|
||||
),
|
||||
],
|
||||
statusLeft: StatusText('${stocks.length} stock${stocks.length == 1 ? '' : 's'} tracked'),
|
||||
child: stocks.isEmpty
|
||||
? Center(
|
||||
child: SizedBox(
|
||||
width: 520,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('Your watchlist is empty').h2,
|
||||
const Gap(8),
|
||||
Text('Add a stock to start tracking signals and news.').muted,
|
||||
const Gap(16),
|
||||
Button.primary(
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (context) => const AddStockDialog(),
|
||||
),
|
||||
child: const Text('Add your first stock'),
|
||||
),
|
||||
],
|
||||
).withPadding(all: 16),
|
||||
),
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 1100,
|
||||
child: Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
children: [
|
||||
for (final stock in stocks)
|
||||
SizedBox(
|
||||
width: 340,
|
||||
child: StockCard(stock: stock),
|
||||
),
|
||||
],
|
||||
).withPadding(all: 20),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _AppName extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return RichText(
|
||||
text: TextSpan(
|
||||
text: 'Augor',
|
||||
style: GoogleFonts.ibmPlexMono(
|
||||
fontSize: 13,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: theme.colorScheme.foreground,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class StockCard extends StatefulWidget {
|
||||
final WatchedStock stock;
|
||||
|
||||
const StockCard({super.key, required this.stock});
|
||||
|
||||
@override
|
||||
State<StockCard> createState() => _StockCardState();
|
||||
}
|
||||
|
||||
class _StockCardState extends State<StockCard> {
|
||||
static final StockPriceService _priceService = StockPriceService();
|
||||
|
||||
List<StockPricePoint> _prices = [];
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPrices();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant StockCard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.stock.ticker != widget.stock.ticker) {
|
||||
_loadPrices();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadPrices() async {
|
||||
try {
|
||||
final prices = await _priceService.fetchPriceHistory(widget.stock.ticker);
|
||||
if (!mounted) return;
|
||||
setState(() => _prices = prices);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _prices = []);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final stock = widget.stock;
|
||||
final signal = stock.latestSignal;
|
||||
|
||||
final probability = signal == null
|
||||
? 'No analysis yet'
|
||||
: '${(signal.impact * 100).toStringAsFixed(1)}% impact';
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () => GoRouter.of(context).go('/stock/${stock.ticker}'),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 80),
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered
|
||||
? theme.colorScheme.background
|
||||
: theme.colorScheme.background.withValues(alpha: 0.85),
|
||||
border: Border.all(color: theme.colorScheme.border, width: 1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: _hovered ? 0.2 : 0.12),
|
||||
blurRadius: 4,
|
||||
spreadRadius: _hovered ? 3 : 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(stock.ticker).h2,
|
||||
const Gap(4),
|
||||
Text(stock.companyName).muted,
|
||||
],
|
||||
),
|
||||
),
|
||||
_SignalBadge(direction: signal?.direction ?? 'neutral'),
|
||||
],
|
||||
),
|
||||
|
||||
const Gap(16),
|
||||
|
||||
Text(probability).semiBold,
|
||||
|
||||
const Gap(12),
|
||||
|
||||
StockPriceChart(
|
||||
prices: _prices,
|
||||
signals: stock.signalHistory,
|
||||
height: 80,
|
||||
compact: true,
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _SignalBadge extends StatelessWidget {
|
||||
final String direction;
|
||||
|
||||
const _SignalBadge({required this.direction});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final color = switch (direction) {
|
||||
'positive' => const Color(0xFF16a34a),
|
||||
'negative' => const Color(0xFFdc2626),
|
||||
_ => theme.colorScheme.mutedForeground,
|
||||
};
|
||||
|
||||
final bg = switch (direction) {
|
||||
'positive' => const Color(0xFF16a34a).withValues(alpha: 0.15),
|
||||
'negative' => const Color(0xFFdc2626).withValues(alpha: 0.15),
|
||||
_ => theme.colorScheme.border.withValues(alpha: 0.5),
|
||||
};
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: color.withValues(alpha: 0.3), width: 1),
|
||||
),
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
text: direction,
|
||||
style: GoogleFonts.ibmPlexMono(
|
||||
fontSize: 10,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AddStockDialog extends StatefulWidget {
|
||||
const AddStockDialog({super.key});
|
||||
|
||||
@override
|
||||
State<AddStockDialog> createState() => _AddStockDialogState();
|
||||
}
|
||||
|
||||
class _AddStockDialogState extends State<AddStockDialog> {
|
||||
String _query = '';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final normalizedQuery = _query.trim().toLowerCase();
|
||||
final results = stockList.where((stock) {
|
||||
if (normalizedQuery.isEmpty) return true;
|
||||
final ticker = (stock['ticker'] ?? '').toLowerCase();
|
||||
final name = (stock['name'] ?? '').toLowerCase();
|
||||
return ticker.contains(normalizedQuery) || name.contains(normalizedQuery);
|
||||
}).toList();
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text('Add stock'),
|
||||
content: SizedBox(
|
||||
width: 420,
|
||||
height: 420,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
placeholder: const Text('Search ticker or company'),
|
||||
onChanged: (value) => setState(() => _query = value),
|
||||
),
|
||||
const Gap(16),
|
||||
Expanded(
|
||||
child: results.isEmpty
|
||||
? Center(child: Text('No matching stocks found.').muted)
|
||||
: ListView.separated(
|
||||
itemCount: results.length,
|
||||
separatorBuilder: (context, index) => const Divider(),
|
||||
itemBuilder: (context, index) {
|
||||
final stock = results[index];
|
||||
final ticker = stock['ticker'] ?? '';
|
||||
final name = stock['name'] ?? '';
|
||||
final alreadyAdded = context
|
||||
.watch<WatchlistProvider>()
|
||||
.stocks
|
||||
.any((w) => w.ticker == ticker);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: alreadyAdded
|
||||
? null
|
||||
: () async {
|
||||
final watchlist = WatchlistProvider.of(context);
|
||||
await watchlist.addStock(ticker, name);
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
color: alreadyAdded
|
||||
? Theme.of(context).colorScheme.secondary.withValues(alpha: 0.5)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(ticker).semiBold,
|
||||
const Gap(2),
|
||||
Text(name).muted.small,
|
||||
],
|
||||
),
|
||||
),
|
||||
if (alreadyAdded) const Text('Added').muted.small,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+70
-421
@@ -1,16 +1,16 @@
|
||||
|
||||
import 'package:capstone_project/providers/settings.dart';
|
||||
import 'package:capstone_project/widgets/navbar.dart';
|
||||
import 'package:capstone_project/widgets/app_shell.dart';
|
||||
import 'package:capstone_project/widgets/panel_layout.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class SettingsPage extends StatefulWidget {
|
||||
|
||||
class SettingsPage extends StatefulWidget {
|
||||
static GoRoute route = GoRoute(
|
||||
path: "/settings",
|
||||
builder: (context, state) => SettingsPage()
|
||||
builder: (context, state) => SettingsPage(),
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -19,431 +19,80 @@ class SettingsPage extends StatefulWidget {
|
||||
|
||||
class _SettingsPageState extends State<SettingsPage> {
|
||||
|
||||
bool _isLoading = true;
|
||||
|
||||
String apiKey = "";
|
||||
List<Feed> feeds = [];
|
||||
String appStorageLocation = "";
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
// TODO: implement initState
|
||||
super.initState();
|
||||
|
||||
loadPage();
|
||||
}
|
||||
|
||||
void loadPage() async {
|
||||
SettingsProvider settings = SettingsProvider.of(context);
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
apiKey = settings.openAIApiKey;
|
||||
feeds = settings.feeds;
|
||||
appStorageLocation = settings.applicationStorageLocation;
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = SettingsProvider.of(context);
|
||||
final apiKey = settings.openRouterApiKey;
|
||||
final storagePath = settings.applicationStorageLocation;
|
||||
|
||||
if (_isLoading) {
|
||||
return Scaffold(
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
final fields = <PanelField>[
|
||||
|
||||
PanelField(
|
||||
section: 'API',
|
||||
label: const Text('OpenRouter key'),
|
||||
child: TextField(
|
||||
placeholder: const Text('sk-or-...'),
|
||||
initialValue: apiKey.isEmpty
|
||||
? ''
|
||||
: '${apiKey.substring(0, apiKey.length >= 8 ? 8 : apiKey.length)}xxxxxx',
|
||||
onChanged: (value) => settings.setOpenRouterApiKey(value),
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
|
||||
return Scaffold(
|
||||
headers: [
|
||||
AppBar(
|
||||
title: Text("Settings"),
|
||||
)
|
||||
],
|
||||
footers: [
|
||||
ProjNavBar(
|
||||
currentPage: "settings",
|
||||
)
|
||||
],
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 600,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
Text(
|
||||
"Open AI"
|
||||
).h4,
|
||||
|
||||
Gap(12),
|
||||
|
||||
Text(
|
||||
"API Key"
|
||||
).small.normal,
|
||||
|
||||
Gap(8),
|
||||
|
||||
TextField(
|
||||
placeholder: Text(
|
||||
"Enter your OpenAI API key"
|
||||
),
|
||||
initialValue: apiKey.substring(0, 8) + "xxxxxx (Redacted for security)" ,
|
||||
onChanged: (value) {
|
||||
SettingsProvider settings = SettingsProvider.of(context);
|
||||
|
||||
settings.setOpenAIApiKey(value);
|
||||
},
|
||||
PanelField(
|
||||
section: 'Application Data',
|
||||
label: const Text('Storage path'),
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
Expanded(
|
||||
child: Text(
|
||||
storagePath.isEmpty ? 'Default' : storagePath,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).colorScheme.mutedForeground,
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
if (apiKey.isEmpty)
|
||||
DestructiveBadge(
|
||||
child: Text(
|
||||
"API key is required to use AI features."
|
||||
),
|
||||
),
|
||||
|
||||
Gap(16),
|
||||
|
||||
Divider(),
|
||||
|
||||
Gap(16),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"News Feeds"
|
||||
).h4,
|
||||
|
||||
Gap(4),
|
||||
|
||||
Text(
|
||||
"Manage your RSS news feeds."
|
||||
).muted.small,
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Button.outline(
|
||||
child: Text(
|
||||
"Add Feed"
|
||||
),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AddFeedDialog()
|
||||
).then((value) {
|
||||
loadPage();
|
||||
});
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
|
||||
Gap(24),
|
||||
|
||||
for (Feed feed in feeds) ...[
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
feed.title.isNotEmpty ? feed.title : feed.url,
|
||||
style: TextStyle(
|
||||
decoration: feed.enabled ? TextDecoration.none : TextDecoration.lineThrough
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Builder(
|
||||
builder: (context2) {
|
||||
return GhostButton(
|
||||
density: ButtonDensity.dense,
|
||||
child: Icon(
|
||||
LucideIcons.ellipsis,
|
||||
|
||||
),
|
||||
onPressed: () {
|
||||
|
||||
|
||||
|
||||
showDropdown(
|
||||
context: context2,
|
||||
builder: (_) {
|
||||
return DropdownMenu(
|
||||
children: [
|
||||
MenuButton(
|
||||
child: Text(
|
||||
"Edit"
|
||||
),
|
||||
trailing: Icon(
|
||||
LucideIcons.filePen
|
||||
),
|
||||
onPressed: (_) {
|
||||
// Navigator.of(context).pop();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AddFeedDialog(
|
||||
existingFeed: feed,
|
||||
)
|
||||
).then((value) {
|
||||
loadPage();
|
||||
});
|
||||
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
),
|
||||
|
||||
Gap(4),
|
||||
|
||||
Switch(
|
||||
value: feed.enabled,
|
||||
onChanged: (value) {
|
||||
SettingsProvider settings = SettingsProvider.of(context);
|
||||
|
||||
settings.updateFeed(
|
||||
feed.id,
|
||||
enabled: value
|
||||
);
|
||||
|
||||
loadPage();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
SelectableText(
|
||||
feed.url,
|
||||
style: TextStyle(
|
||||
decoration: feed.enabled ? TextDecoration.none : TextDecoration.lineThrough
|
||||
),
|
||||
).muted.small,
|
||||
|
||||
Gap(12),
|
||||
],
|
||||
|
||||
Gap(12),
|
||||
|
||||
Divider(),
|
||||
|
||||
Gap(16),
|
||||
|
||||
Text(
|
||||
"Application Data"
|
||||
).h4,
|
||||
|
||||
Gap(12),
|
||||
|
||||
Text(
|
||||
"Application Storage Location"
|
||||
).small.normal,
|
||||
|
||||
Gap(8),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
placeholder: Text(
|
||||
"Select a diretory using Browse"
|
||||
),
|
||||
initialValue: appStorageLocation,
|
||||
enabled: false,
|
||||
),
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
Button.outline(
|
||||
child: Text(
|
||||
"Browse"
|
||||
),
|
||||
onPressed: () {
|
||||
|
||||
FilePicker.platform.getDirectoryPath().then((selectedPath) {
|
||||
if (selectedPath != null) {
|
||||
SettingsProvider settings = SettingsProvider.of(context);
|
||||
|
||||
settings.setApplicationStorageLocation(selectedPath);
|
||||
|
||||
loadPage();
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
},
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
IconButton.destructive(
|
||||
icon: Icon(
|
||||
LucideIcons.refreshCcw
|
||||
),
|
||||
onPressed: () async {
|
||||
SettingsProvider settings = SettingsProvider.of(context);
|
||||
|
||||
settings.setApplicationStorageLocation((await getApplicationDocumentsDirectory()).path);
|
||||
|
||||
loadPage();
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
|
||||
],
|
||||
).withMargin(
|
||||
all: 10
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Gap(8),
|
||||
|
||||
Button.outline(
|
||||
onPressed: () {
|
||||
FilePicker.platform.getDirectoryPath().then((path) {
|
||||
if (path != null) {
|
||||
settings.setApplicationStorageLocation(path);
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
},
|
||||
child: const Text('Browse'),
|
||||
),
|
||||
|
||||
const Gap(4),
|
||||
|
||||
IconButton.destructive(
|
||||
icon: const Icon(LucideIcons.refreshCcw),
|
||||
onPressed: () async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
settings.setApplicationStorageLocation(dir.path);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
).withPadding(all: 24),
|
||||
),
|
||||
|
||||
];
|
||||
|
||||
return AugorShell(
|
||||
titleTag: 'Settings',
|
||||
statusLeft: const StatusText('Augor · Settings'),
|
||||
child: SingleChildScrollView(
|
||||
child: PanelList(fields: fields),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AddFeedDialog extends StatelessWidget {
|
||||
|
||||
Feed? existingFeed;
|
||||
|
||||
AddFeedDialog({super.key, this.existingFeed});
|
||||
|
||||
InputKey feedTitleKey = InputKey("feed_title");
|
||||
InputKey feedUrlKey = InputKey("feed_url");
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
existingFeed == null ? "Add Feed" : "Edit Feed"
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 400,
|
||||
child: Form(
|
||||
onSubmit: (context, values) {
|
||||
String title = values[feedTitleKey] ?? "";
|
||||
String url = values[feedUrlKey] ?? "";
|
||||
|
||||
SettingsProvider settings = SettingsProvider.of(context);
|
||||
|
||||
if (existingFeed != null) {
|
||||
settings.updateFeed(
|
||||
existingFeed!.id,
|
||||
title: title,
|
||||
url: url
|
||||
);
|
||||
} else {
|
||||
settings.addFeed(Feed(
|
||||
title: title,
|
||||
url: url,
|
||||
enabled: true
|
||||
));
|
||||
}
|
||||
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
Text(
|
||||
"Add a custom RSS feed to your news sources."
|
||||
).muted,
|
||||
|
||||
Gap(16),
|
||||
|
||||
FormField(
|
||||
key: feedTitleKey,
|
||||
label: Text(
|
||||
"Title"
|
||||
),
|
||||
validator: ConditionalValidator((value) {
|
||||
if (value is String) {
|
||||
return value.trim().isNotEmpty;
|
||||
}
|
||||
return false;
|
||||
}, message: "Title cannot be empty"),
|
||||
child: TextField(
|
||||
initialValue: existingFeed?.title,
|
||||
),
|
||||
),
|
||||
|
||||
Gap(12),
|
||||
|
||||
FormField(
|
||||
key: feedUrlKey,
|
||||
label: Text(
|
||||
"Resource URL"
|
||||
),
|
||||
validator: ConditionalValidator((value) {
|
||||
if (value is String) {
|
||||
return value.trim().isNotEmpty;
|
||||
}
|
||||
return false;
|
||||
}, message: "URL cannot be empty"),
|
||||
child: TextField(
|
||||
initialValue: existingFeed?.url,
|
||||
)
|
||||
),
|
||||
|
||||
Gap(24),
|
||||
|
||||
Text(
|
||||
"Only use valid RSS feed URLs. Preferably only use sites you trust, and specifically business and financial news sources."
|
||||
),
|
||||
|
||||
Gap(24),
|
||||
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
|
||||
Button.outline(
|
||||
child: Text(
|
||||
"Cancel"
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
SubmitButton(
|
||||
child: Text(
|
||||
existingFeed != null ? "Update Feed" : "Add Feed"
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,788 @@
|
||||
import 'package:capstone_project/models/watched_stock.dart';
|
||||
import 'package:capstone_project/providers/settings.dart';
|
||||
import 'package:capstone_project/providers/watchlist.dart';
|
||||
import 'package:capstone_project/services/stock_price_service.dart';
|
||||
import 'package:capstone_project/utils/event_clusterer.dart';
|
||||
import 'package:capstone_project/utils/signal_generator.dart';
|
||||
import 'package:capstone_project/services/news_search_service.dart';
|
||||
|
||||
import 'package:capstone_project/widgets/app_shell.dart';
|
||||
import 'package:capstone_project/widgets/event_signal_card.dart';
|
||||
import 'package:capstone_project/widgets/signal_probability_chart.dart';
|
||||
import 'package:capstone_project/widgets/stock_price_chart.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
|
||||
class StockDashboardPage extends StatefulWidget {
|
||||
final String ticker;
|
||||
|
||||
const StockDashboardPage({super.key, required this.ticker});
|
||||
|
||||
static GoRoute route = GoRoute(
|
||||
path: '/stock/:ticker',
|
||||
builder: (context, state) => StockDashboardPage(
|
||||
ticker: state.pathParameters['ticker']!,
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
State<StockDashboardPage> createState() => _StockDashboardPageState();
|
||||
}
|
||||
|
||||
class _StockDashboardPageState extends State<StockDashboardPage> {
|
||||
static final StockPriceService _priceService = StockPriceService();
|
||||
|
||||
bool _isProcessing = false;
|
||||
bool _isLoadingPrices = false;
|
||||
String _status = 'Ready';
|
||||
String? _error;
|
||||
List<StockPricePoint> _prices = [];
|
||||
|
||||
_TimeRange _selectedRange = _TimeRange.oneMonth;
|
||||
int _periodOffset = 0; // 0 = current period, negative = further back
|
||||
|
||||
DateTime get _fromDate {
|
||||
final now = DateTime.now();
|
||||
switch (_selectedRange) {
|
||||
case _TimeRange.oneDay:
|
||||
final d = DateTime(now.year, now.month, now.day).add(Duration(days: _periodOffset));
|
||||
return d;
|
||||
case _TimeRange.oneWeek:
|
||||
final monday = now.subtract(Duration(days: now.weekday - 1));
|
||||
return DateTime(monday.year, monday.month, monday.day).add(Duration(days: _periodOffset * 7));
|
||||
case _TimeRange.oneMonth:
|
||||
return DateTime(now.year, now.month + _periodOffset, 1);
|
||||
case _TimeRange.oneYear:
|
||||
return DateTime(now.year + _periodOffset, 1, 1);
|
||||
case _TimeRange.max:
|
||||
return DateTime(2000, 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
DateTime get _toDate {
|
||||
final now = DateTime.now();
|
||||
switch (_selectedRange) {
|
||||
case _TimeRange.oneDay:
|
||||
return _fromDate.add(const Duration(hours: 23, minutes: 59, seconds: 59));
|
||||
case _TimeRange.oneWeek:
|
||||
return _fromDate.add(const Duration(days: 6, hours: 23, minutes: 59, seconds: 59));
|
||||
case _TimeRange.oneMonth:
|
||||
final f = _fromDate;
|
||||
return DateTime(f.year, f.month + 1, 0, 23, 59, 59);
|
||||
case _TimeRange.oneYear:
|
||||
return DateTime(now.year + _periodOffset, 12, 31, 23, 59, 59);
|
||||
case _TimeRange.max:
|
||||
return now;
|
||||
}
|
||||
}
|
||||
|
||||
// null = all, 'forecasting', 'reactive'
|
||||
String? _natureFilter;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPrices();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant StockDashboardPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.ticker != widget.ticker) {
|
||||
_loadPrices();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadPrices() async {
|
||||
setState(() => _isLoadingPrices = true);
|
||||
try {
|
||||
final (range, interval) = switch (_selectedRange) {
|
||||
_TimeRange.oneDay => ('5d', '30m'),
|
||||
_TimeRange.oneWeek => ('3mo', '1d'),
|
||||
_TimeRange.oneMonth => ('1y', '1d'),
|
||||
_TimeRange.oneYear => ('5y', '1wk'),
|
||||
_TimeRange.max => ('max', '1mo'),
|
||||
};
|
||||
final prices = await _priceService.fetchPriceHistory(widget.ticker, range: range, interval: interval);
|
||||
if (!mounted) return;
|
||||
setState(() => _prices = prices);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _prices = []);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoadingPrices = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Consumer<WatchlistProvider>(
|
||||
builder: (context, watchlist, _) {
|
||||
final stock = watchlist.stocks
|
||||
.where((item) => item.ticker == widget.ticker)
|
||||
.firstOrNull;
|
||||
|
||||
if (stock == null) {
|
||||
return AugorShell(
|
||||
titleTag: widget.ticker,
|
||||
headerLeading: [_BackButton()],
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 520,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('Stock not found').h2,
|
||||
const Gap(8),
|
||||
Text('This watchlist item no longer exists.').muted,
|
||||
const Gap(16),
|
||||
Button.outline(
|
||||
onPressed: () => GoRouter.of(context).go('/'),
|
||||
child: const Text('Back to watchlist'),
|
||||
),
|
||||
],
|
||||
).withPadding(all: 16),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final signalCount = stock.signalHistory.length;
|
||||
final latestSignal = stock.latestSignal;
|
||||
|
||||
return AugorShell(
|
||||
titleTag: stock.ticker,
|
||||
headerLeading: [
|
||||
_BackButton(),
|
||||
const SizedBox(width: 12),
|
||||
_TickerLabel(stock: stock),
|
||||
],
|
||||
headerTrailing: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (stock.signalHistory.isNotEmpty)
|
||||
Button.outline(
|
||||
onPressed: _isProcessing ? null : () async {
|
||||
await WatchlistProvider.of(context).updateSignals(stock.ticker, []);
|
||||
},
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Button.primary(
|
||||
onPressed: _isProcessing ? null : () => _runAnalysis(context, stock),
|
||||
child: Text(_isProcessing ? 'Analyzing...' : 'Run Analysis'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
statusLeft: StatusText(_status),
|
||||
statusRight: latestSignal != null
|
||||
? StatusText('impact ${(latestSignal.impact * 100).toStringAsFixed(0)}% · $signalCount signal${signalCount == 1 ? '' : 's'}')
|
||||
: null,
|
||||
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
// ── left: charts (fixed, non-scrolling) ──────────────────────
|
||||
SizedBox(
|
||||
width: 520,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
if (_error != null)
|
||||
Container(
|
||||
margin: const EdgeInsets.fromLTRB(20, 20, 0, 0),
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFdc2626).withValues(alpha: 0.1),
|
||||
border: Border.all(color: const Color(0xFFdc2626).withValues(alpha: 0.3), width: 1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(_error!, style: const TextStyle(color: Color(0xFFdc2626), fontSize: 12)),
|
||||
),
|
||||
|
||||
if (_isProcessing)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 0, 0),
|
||||
child: const SizedBox(
|
||||
width: double.infinity,
|
||||
child: LinearProgressIndicator(),
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
|
||||
child: _TimeRangeBar(
|
||||
selected: _selectedRange,
|
||||
offset: _periodOffset,
|
||||
onRangeChanged: (r) {
|
||||
setState(() {
|
||||
_selectedRange = r;
|
||||
_periodOffset = 0;
|
||||
});
|
||||
_loadPrices();
|
||||
},
|
||||
onOffsetChanged: (o) => setState(() => _periodOffset = o),
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SectionHeader(
|
||||
title: 'Price',
|
||||
trailing: _isLoadingPrices ? Text('loading...').muted.small : null,
|
||||
),
|
||||
const Gap(12),
|
||||
StockPriceChart(
|
||||
prices: _prices.where((p) =>
|
||||
!p.date.isBefore(_fromDate) && !p.date.isAfter(_toDate)
|
||||
).toList(),
|
||||
signals: stock.signalHistory.where((s) =>
|
||||
!s.createdAt.isBefore(_fromDate) && !s.createdAt.isAfter(_toDate) &&
|
||||
(_natureFilter == null || s.nature == _natureFilter)
|
||||
).toList(),
|
||||
height: 220,
|
||||
),
|
||||
|
||||
const Gap(28),
|
||||
|
||||
_SectionHeader(title: 'Signal history'),
|
||||
const Gap(12),
|
||||
SignalProbabilityChart(
|
||||
signals: stock.signalHistory.where((s) =>
|
||||
!s.createdAt.isBefore(_fromDate) && !s.createdAt.isAfter(_toDate) &&
|
||||
(_natureFilter == null || s.nature == _natureFilter)
|
||||
).toList(),
|
||||
height: 200,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// vertical divider between columns
|
||||
VerticalDivider(color: theme.colorScheme.border, width: 1),
|
||||
|
||||
// ── right: scrollable signals list ───────────────────────────
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
// filter bar
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: theme.colorScheme.border, width: 1)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_SectionHeader(
|
||||
title: 'Signals',
|
||||
trailing: Text('${stock.signalHistory.length}').muted,
|
||||
),
|
||||
const Spacer(),
|
||||
_NatureFilterChip(
|
||||
label: 'All',
|
||||
active: _natureFilter == null,
|
||||
onTap: () => setState(() => _natureFilter = null),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_NatureFilterChip(
|
||||
label: 'Forecasting',
|
||||
active: _natureFilter == 'forecasting',
|
||||
onTap: () => setState(() => _natureFilter =
|
||||
_natureFilter == 'forecasting' ? null : 'forecasting'),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_NatureFilterChip(
|
||||
label: 'Reactive',
|
||||
active: _natureFilter == 'reactive',
|
||||
onTap: () => setState(() => _natureFilter =
|
||||
_natureFilter == 'reactive' ? null : 'reactive'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// list
|
||||
Expanded(
|
||||
child: Builder(builder: (context) {
|
||||
final filtered = stock.signalHistory
|
||||
.where((s) => _natureFilter == null || s.nature == _natureFilter)
|
||||
.toList()
|
||||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
|
||||
if (stock.signalHistory.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Text('No signals yet. Run analysis to populate this dashboard.').muted,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (filtered.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Text('No $_natureFilter signals in this window.').muted,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
|
||||
itemCount: filtered.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||
itemBuilder: (_, i) => EventSignalCard(signal: filtered[i]),
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _runAnalysis(BuildContext context, WatchedStock stock) async {
|
||||
final settings = SettingsProvider.of(context);
|
||||
final watchlist = WatchlistProvider.of(context);
|
||||
|
||||
if (settings.openRouterApiKey.trim().isEmpty) {
|
||||
setState(() {
|
||||
_error = 'Add your OpenRouter API key in Settings first.';
|
||||
_status = 'Missing API key';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isProcessing = true;
|
||||
_error = null;
|
||||
_status = 'Fetching articles...';
|
||||
});
|
||||
|
||||
try {
|
||||
setState(() => _status = 'Fetching news...');
|
||||
final newsService = NewsSearchService();
|
||||
final scopedItems = await newsService.search(
|
||||
ticker: stock.ticker,
|
||||
companyName: stock.companyName,
|
||||
from: _fromDate,
|
||||
to: _toDate,
|
||||
);
|
||||
|
||||
if (scopedItems.isEmpty) {
|
||||
await watchlist.updateSignals(stock.ticker, []);
|
||||
setState(() => _status = 'Done — 0 matching articles');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _status = 'Clustering ${scopedItems.length} articles...');
|
||||
final clusterer = EventClusterer();
|
||||
final clusters = await clusterer.cluster(scopedItems);
|
||||
|
||||
if (clusters.isEmpty) {
|
||||
await watchlist.updateSignals(stock.ticker, []);
|
||||
setState(() => _status = 'Done — 0 event clusters');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _status = 'Generating signals (${clusters.length} clusters)...');
|
||||
final signalGenerator = SignalGenerator(apiKey: settings.openRouterApiKey);
|
||||
final signals = await signalGenerator.generateSignals(
|
||||
clusters,
|
||||
ticker: stock.ticker,
|
||||
companyName: stock.companyName,
|
||||
);
|
||||
await watchlist.updateSignals(stock.ticker, signals);
|
||||
await _loadPrices();
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _status = 'Done — ${signals.length} signal${signals.length == 1 ? '' : 's'}');
|
||||
} catch (e) {
|
||||
print('Stock analysis failed for ${stock.ticker}: $e');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_status = 'Failed';
|
||||
});
|
||||
} finally {
|
||||
if (mounted) setState(() => _isProcessing = false);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class _BackButton extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GhostButton(
|
||||
density: ButtonDensity.dense,
|
||||
onPressed: () => GoRouter.of(context).go('/'),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(LucideIcons.arrowLeft, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
const Text('Back'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _TickerLabel extends StatelessWidget {
|
||||
final WatchedStock stock;
|
||||
|
||||
const _TickerLabel({required this.stock});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
text: stock.ticker,
|
||||
style: GoogleFonts.ibmPlexMono(
|
||||
fontSize: 13,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: theme.colorScheme.foreground,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
text: stock.companyName,
|
||||
style: GoogleFonts.ibmPlexMono(
|
||||
fontSize: 10,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
enum _TimeRange { oneDay, oneWeek, oneMonth, oneYear, max }
|
||||
|
||||
|
||||
class _TimeRangeBar extends StatelessWidget {
|
||||
final _TimeRange selected;
|
||||
final int offset;
|
||||
final ValueChanged<_TimeRange> onRangeChanged;
|
||||
final ValueChanged<int> onOffsetChanged;
|
||||
|
||||
const _TimeRangeBar({
|
||||
required this.selected,
|
||||
required this.offset,
|
||||
required this.onRangeChanged,
|
||||
required this.onOffsetChanged,
|
||||
});
|
||||
|
||||
static const _labels = {
|
||||
_TimeRange.oneDay: '1D',
|
||||
_TimeRange.oneWeek: '1W',
|
||||
_TimeRange.oneMonth: '1M',
|
||||
_TimeRange.oneYear: '1Y',
|
||||
_TimeRange.max: 'MAX',
|
||||
};
|
||||
|
||||
String _periodLabel(DateTime from, DateTime to) {
|
||||
const months = ['Jan','Feb','Mar','Apr','May','Jun',
|
||||
'Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
switch (selected) {
|
||||
case _TimeRange.oneDay:
|
||||
return '${months[from.month - 1]} ${from.day}, ${from.year}';
|
||||
case _TimeRange.oneWeek:
|
||||
if (from.month == to.month) {
|
||||
return '${months[from.month - 1]} ${from.day}–${to.day}';
|
||||
}
|
||||
return '${months[from.month - 1]} ${from.day} – ${months[to.month - 1]} ${to.day}';
|
||||
case _TimeRange.oneMonth:
|
||||
return '${months[from.month - 1]} ${from.year}';
|
||||
case _TimeRange.oneYear:
|
||||
return '${from.year}';
|
||||
case _TimeRange.max:
|
||||
return 'All Time';
|
||||
}
|
||||
}
|
||||
|
||||
DateTime _computeFrom() {
|
||||
final now = DateTime.now();
|
||||
switch (selected) {
|
||||
case _TimeRange.oneDay:
|
||||
return DateTime(now.year, now.month, now.day).add(Duration(days: offset));
|
||||
case _TimeRange.oneWeek:
|
||||
final monday = now.subtract(Duration(days: now.weekday - 1));
|
||||
return DateTime(monday.year, monday.month, monday.day).add(Duration(days: offset * 7));
|
||||
case _TimeRange.oneMonth:
|
||||
return DateTime(now.year, now.month + offset, 1);
|
||||
case _TimeRange.oneYear:
|
||||
return DateTime(now.year + offset, 1, 1);
|
||||
case _TimeRange.max:
|
||||
return DateTime(2000, 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final monoStyle = GoogleFonts.ibmPlexMono(
|
||||
fontSize: 10,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
);
|
||||
|
||||
final from = _computeFrom();
|
||||
// reuse parent's _toDate logic via same calculation
|
||||
final now = DateTime.now();
|
||||
final DateTime to;
|
||||
switch (selected) {
|
||||
case _TimeRange.oneDay:
|
||||
to = from.add(const Duration(hours: 23, minutes: 59, seconds: 59));
|
||||
case _TimeRange.oneWeek:
|
||||
to = from.add(const Duration(days: 6, hours: 23, minutes: 59, seconds: 59));
|
||||
case _TimeRange.oneMonth:
|
||||
to = DateTime(from.year, from.month + 1, 0, 23, 59, 59);
|
||||
case _TimeRange.oneYear:
|
||||
to = DateTime(now.year + offset, 12, 31, 23, 59, 59);
|
||||
case _TimeRange.max:
|
||||
to = now;
|
||||
}
|
||||
|
||||
final canGoForward = selected != _TimeRange.max && offset < 0;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
|
||||
// preset chips
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: theme.colorScheme.border, width: 1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: _TimeRange.values.map((range) {
|
||||
final isActive = selected == range;
|
||||
final isLast = range == _TimeRange.max;
|
||||
return GestureDetector(
|
||||
onTap: () => onRangeChanged(range),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 80),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? theme.colorScheme.border : Colors.transparent,
|
||||
border: isLast ? null : Border(
|
||||
right: BorderSide(color: theme.colorScheme.border, width: 1),
|
||||
),
|
||||
),
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
text: _labels[range],
|
||||
style: monoStyle.copyWith(
|
||||
color: isActive
|
||||
? theme.colorScheme.foreground
|
||||
: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
|
||||
if (selected != _TimeRange.max) ...[
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// prev
|
||||
GestureDetector(
|
||||
onTap: () => onOffsetChanged(offset - 1),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(5),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: theme.colorScheme.border, width: 1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Icon(LucideIcons.chevronLeft, size: 10, color: theme.colorScheme.mutedForeground),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 4),
|
||||
|
||||
// period label
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: theme.colorScheme.border, width: 1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
text: _periodLabel(from, to),
|
||||
style: monoStyle.copyWith(color: theme.colorScheme.foreground),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 4),
|
||||
|
||||
// next
|
||||
GestureDetector(
|
||||
onTap: canGoForward ? () => onOffsetChanged(offset + 1) : null,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 80),
|
||||
padding: const EdgeInsets.all(5),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: canGoForward
|
||||
? theme.colorScheme.border
|
||||
: theme.colorScheme.border.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 10,
|
||||
color: canGoForward
|
||||
? theme.colorScheme.mutedForeground
|
||||
: theme.colorScheme.mutedForeground.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _NatureFilterChip extends StatefulWidget {
|
||||
final String label;
|
||||
final bool active;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _NatureFilterChip({required this.label, required this.active, required this.onTap});
|
||||
|
||||
@override
|
||||
State<_NatureFilterChip> createState() => _NatureFilterChipState();
|
||||
}
|
||||
|
||||
class _NatureFilterChipState extends State<_NatureFilterChip> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 80),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.active
|
||||
? theme.colorScheme.border
|
||||
: _hovered
|
||||
? theme.colorScheme.border.withValues(alpha: 0.4)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: widget.active
|
||||
? theme.colorScheme.border
|
||||
: theme.colorScheme.border.withValues(alpha: 0.5),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
text: widget.label,
|
||||
style: GoogleFonts.ibmPlexMono(
|
||||
fontSize: 10,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: widget.active
|
||||
? theme.colorScheme.foreground
|
||||
: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final Widget? trailing;
|
||||
|
||||
const _SectionHeader({required this.title, this.trailing});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.4,
|
||||
color: theme.colorScheme.foreground,
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[
|
||||
const Gap(8),
|
||||
trailing!,
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user