788 lines
27 KiB
Dart
788 lines
27 KiB
Dart
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!,
|
||
],
|
||
],
|
||
);
|
||
}
|
||
}
|