Refactor project structure and enhance stock watchlist functionality
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
@@ -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}';
|
||||
}
|
||||
Reference in New Issue
Block a user