- Add NewsTickerWidget with horizontal auto-scrolling at 40px/sec - Add NewsData and NewsHeadline Freezed models - Add news datasource fetching from /tools/news endpoint - Add news provider with 30-minute auto-refresh - Place ticker between Welcome card and System Stats on dashboard - Show placeholder headlines when no data (italic, muted style) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
206 lines
6.1 KiB
Dart
206 lines
6.1 KiB
Dart
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<NewsTickerWidget> createState() => _NewsTickerWidgetState();
|
|
}
|
|
|
|
class _NewsTickerWidgetState extends State<NewsTickerWidget>
|
|
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<double> 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),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|