feat: add news ticker widget for scrolling headlines
- 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>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
a376482cd1
commit
4331555f84
@@ -74,11 +74,11 @@ class AirQualityWidget extends StatelessWidget {
|
||||
height: 170,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Main content positioned above horizon
|
||||
// Main content positioned above horizon (8px gap)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 50,
|
||||
bottom: 58,
|
||||
child: Row(
|
||||
children: [
|
||||
// AQI number with colored background
|
||||
@@ -138,12 +138,12 @@ class AirQualityWidget extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
// Horizon divider at fixed position
|
||||
// Horizon divider at fixed position (matches Sun Position horizonY)
|
||||
if (aqi.pollutants.isNotEmpty)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 38,
|
||||
bottom: 50,
|
||||
child: const Divider(height: 1),
|
||||
),
|
||||
// Footer below horizon
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -76,11 +76,11 @@ class WeatherWidget extends StatelessWidget {
|
||||
height: 170,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Main content positioned above horizon
|
||||
// Main content positioned above horizon (8px gap)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 50,
|
||||
bottom: 58,
|
||||
child: Row(
|
||||
children: [
|
||||
// Weather icon in box
|
||||
@@ -145,12 +145,12 @@ class WeatherWidget extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
// Horizon divider at fixed position
|
||||
// Horizon divider at fixed position (matches Sun Position horizonY)
|
||||
if (weather.humidity != null || weather.windSpeed != null)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 38,
|
||||
bottom: 50,
|
||||
child: const Divider(height: 1),
|
||||
),
|
||||
// Footer below horizon
|
||||
|
||||
@@ -4,4 +4,5 @@ library;
|
||||
export 'air_quality_widget.dart';
|
||||
export 'gauge_widget.dart';
|
||||
export 'icon_picker.dart';
|
||||
export 'news_ticker_widget.dart';
|
||||
export 'weather_widget.dart';
|
||||
|
||||
Reference in New Issue
Block a user