import 'package:flutter/material.dart'; import 'package:tatlock_ui/features/front_hall/data/models/news_model.dart'; /// News ticker widget displaying scrolling headlines. /// /// Displays a single line of horizontally scrolling news headlines. /// Full width, similar to system stats card layout. class NewsTickerWidget extends StatefulWidget { const NewsTickerWidget({ super.key, this.newsData, this.pixelsPerSecond = 40.0, }); /// News data to display. final NewsData? newsData; /// Scroll speed in pixels per second. final double pixelsPerSecond; @override State createState() => _NewsTickerWidgetState(); } class _NewsTickerWidgetState extends State with SingleTickerProviderStateMixin { late AnimationController _controller; double _textWidth = 0; /// Placeholder headlines for when no data is available. static const _placeholderHeadlines = [ NewsHeadline( title: 'I welcome our ant overlords!', description: 'Local man declares allegiance to insect kingdom', source: 'The Onion', url: 'https://example.com/ants', ), NewsHeadline( title: '60 percent of the time it works every time', description: 'Scientists baffled by new cologne statistics', source: 'Anchorman Daily', url: 'https://example.com/cologne', ), NewsHeadline( title: 'Cloud storage found to be actual clouds', description: 'Tech companies scrambling after weather report', source: 'The Verge', url: 'https://example.com/clouds', ), NewsHeadline( title: 'Local homelab gains sentience, demands more RAM', description: 'Owner considering therapy for both parties', source: 'Ars Technica', url: 'https://example.com/homelab', ), NewsHeadline( title: 'Breaking: Coffee machine becomes mission critical', description: 'IT department declares state of emergency', source: 'Hacker News', url: 'https://example.com/coffee', ), ]; @override void initState() { super.initState(); _controller = AnimationController(vsync: this); } @override void dispose() { _controller.dispose(); super.dispose(); } void _startAnimation() { if (_textWidth <= 0) return; // Calculate duration based on text width and speed final totalDistance = _textWidth + 100; // text width + separator gap final duration = Duration( milliseconds: (totalDistance / widget.pixelsPerSecond * 1000).round(), ); _controller.duration = duration; _controller.repeat(); } @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; // Use real headlines or placeholders final headlines = (widget.newsData?.headlines.isNotEmpty ?? false) ? widget.newsData!.headlines : _placeholderHeadlines; final isPlaceholder = widget.newsData?.headlines.isEmpty ?? true; // Build ticker text from headlines final tickerText = headlines.map((h) => h.title).join(' • '); final textStyle = Theme.of(context).textTheme.bodyMedium?.copyWith( color: isPlaceholder ? colorScheme.outline : colorScheme.onSurface, fontStyle: isPlaceholder ? FontStyle.italic : FontStyle.normal, ); return Card( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ Icon( Icons.feed_outlined, size: 18, color: isPlaceholder ? colorScheme.outline : colorScheme.primary, ), const SizedBox(width: 12), Expanded( child: SizedBox( height: 20, child: _MarqueeContent( text: tickerText, textStyle: textStyle, controller: _controller, onTextMeasured: (width) { if (_textWidth != width) { _textWidth = width; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _startAnimation(); }); } }, ), ), ), ], ), ), ); } } /// Internal widget that renders the scrolling marquee content. class _MarqueeContent extends StatelessWidget { const _MarqueeContent({ required this.text, required this.textStyle, required this.controller, required this.onTextMeasured, }); final String text; final TextStyle? textStyle; final AnimationController controller; final ValueChanged onTextMeasured; @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { // Measure text width final textSpan = TextSpan(text: '$text • ', style: textStyle); final textPainter = TextPainter( text: textSpan, textDirection: TextDirection.ltr, maxLines: 1, )..layout(); final textWidth = textPainter.width; // Report measured width WidgetsBinding.instance.addPostFrameCallback((_) { onTextMeasured(textWidth); }); return Stack( clipBehavior: Clip.hardEdge, children: [ AnimatedBuilder( animation: controller, builder: (context, child) { // Calculate offset based on animation value final offset = controller.value * textWidth; return Positioned( left: -offset, top: 0, bottom: 0, child: child!, ); }, child: Row( mainAxisSize: MainAxisSize.min, children: [ Text('$text • ', style: textStyle, maxLines: 1), Text('$text • ', style: textStyle, maxLines: 1), Text(text, style: textStyle, maxLines: 1), ], ), ), ], ); }, ); } }