Auger/lib/pages/home.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,
],
),
),
);
},
),
),
],
),
),
);
}
}