359 lines
11 KiB
Dart
359 lines
11 KiB
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) => const HomePage(),
|
|
);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Consumer<WatchlistProvider>(
|
|
builder: (context, watchlist, _) {
|
|
final stocks = watchlist.stocks;
|
|
|
|
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,
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|