From 4331555f8499560fb6d7f67bbdc1b4a8794ca16f Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 8 Jan 2026 21:51:32 +0100 Subject: [PATCH] feat: add news ticker widget for scrolling headlines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 14 ++ .../data/datasources/news_datasource.dart | 38 ++++ .../front_hall/data/models/news_model.dart | 34 +++ .../presentation/providers/news_provider.dart | 27 +++ .../widgets/dashboard_content.dart | 17 ++ lib/shared/widgets/air_quality_widget.dart | 8 +- lib/shared/widgets/news_ticker_widget.dart | 205 ++++++++++++++++++ lib/shared/widgets/weather_widget.dart | 8 +- lib/shared/widgets/widgets.dart | 1 + pubspec.yaml | 2 +- 10 files changed, 345 insertions(+), 9 deletions(-) create mode 100644 lib/features/front_hall/data/datasources/news_datasource.dart create mode 100644 lib/features/front_hall/data/models/news_model.dart create mode 100644 lib/features/front_hall/presentation/providers/news_provider.dart create mode 100644 lib/shared/widgets/news_ticker_widget.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 840f81e..e44c80a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.6.0] - 2026-01-08 + +### Added + +- **News Ticker Widget** - Scrolling news headlines on dashboard + - Full-width ticker between Welcome card and System Stats + - Horizontal auto-scrolling at 40px/second with seamless looping + - Fetches headlines from `/tools/news` endpoint + - Placeholder headlines shown when no data (italic, muted style) + - Auto-refresh every 30 minutes +- News data model (`NewsData`, `NewsHeadline`) with Freezed +- News datasource calling `GET /tools/news` +- News provider with `hasNews` helper + ## [1.5.9] - 2026-01-08 ### Changed diff --git a/lib/features/front_hall/data/datasources/news_datasource.dart b/lib/features/front_hall/data/datasources/news_datasource.dart new file mode 100644 index 0000000..4152b09 --- /dev/null +++ b/lib/features/front_hall/data/datasources/news_datasource.dart @@ -0,0 +1,38 @@ +import 'package:dio/dio.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:tatlock_ui/core/api/api_client.dart'; +import 'package:tatlock_ui/features/front_hall/data/models/news_model.dart'; + +part 'news_datasource.g.dart'; + +/// Data source for news data operations. +/// +/// Fetches news headlines from Core API. +class NewsDatasource { + NewsDatasource(this._dio); + + final Dio _dio; + + static const _basePath = '/tools/news'; + + /// Gets current news headlines. + /// + /// Returns headlines for the news ticker. + Future getNews() async { + final response = await _dio.get>(_basePath); + final data = response.data; + + if (data == null) { + throw Exception('Failed to fetch news data'); + } + + return NewsData.fromJson(data); + } +} + +/// Provides the news datasource. +@riverpod +NewsDatasource newsDatasource(Ref ref) { + final dio = ref.watch(coreApiClientProvider); + return NewsDatasource(dio); +} diff --git a/lib/features/front_hall/data/models/news_model.dart b/lib/features/front_hall/data/models/news_model.dart new file mode 100644 index 0000000..0542f64 --- /dev/null +++ b/lib/features/front_hall/data/models/news_model.dart @@ -0,0 +1,34 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'news_model.freezed.dart'; +part 'news_model.g.dart'; + +/// News response from Core API. +/// Contains headlines for the news ticker. +@freezed +sealed class NewsData with _$NewsData { + const factory NewsData({ + required List headlines, + String? category, + List? sources, + @JsonKey(name: 'updated_at') required DateTime updatedAt, + String? user, + }) = _NewsData; + + factory NewsData.fromJson(Map json) => + _$NewsDataFromJson(json); +} + +/// Single news headline. +@freezed +sealed class NewsHeadline with _$NewsHeadline { + const factory NewsHeadline({ + required String title, + String? description, + String? source, + String? url, + }) = _NewsHeadline; + + factory NewsHeadline.fromJson(Map json) => + _$NewsHeadlineFromJson(json); +} diff --git a/lib/features/front_hall/presentation/providers/news_provider.dart b/lib/features/front_hall/presentation/providers/news_provider.dart new file mode 100644 index 0000000..67cdd69 --- /dev/null +++ b/lib/features/front_hall/presentation/providers/news_provider.dart @@ -0,0 +1,27 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:tatlock_ui/features/front_hall/data/datasources/news_datasource.dart'; +import 'package:tatlock_ui/features/front_hall/data/models/news_model.dart'; + +part 'news_provider.g.dart'; + +/// Fetches news headlines from Core API. +/// +/// Auto-invalidates to keep data fresh. +/// News data in Qdrant has TTL, so periodic refresh is reasonable. +@riverpod +Future news(Ref ref) async { + final datasource = ref.watch(newsDatasourceProvider); + return datasource.getNews(); +} + +/// Provides whether news data is available. +/// +/// Used for conditional rendering of NewsTickerWidget. +@riverpod +bool hasNews(Ref ref) { + final asyncValue = ref.watch(newsProvider); + return asyncValue.maybeWhen( + data: (data) => data.headlines.isNotEmpty, + orElse: () => false, + ); +} diff --git a/lib/features/front_hall/presentation/widgets/dashboard_content.dart b/lib/features/front_hall/presentation/widgets/dashboard_content.dart index 6820828..2148172 100644 --- a/lib/features/front_hall/presentation/widgets/dashboard_content.dart +++ b/lib/features/front_hall/presentation/widgets/dashboard_content.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tatlock_ui/features/front_hall/data/models/system_stats_model.dart'; import 'package:tatlock_ui/features/front_hall/presentation/providers/environment_provider.dart'; +import 'package:tatlock_ui/features/front_hall/presentation/providers/news_provider.dart'; import 'package:tatlock_ui/features/front_hall/presentation/providers/system_stats_provider.dart'; import 'package:tatlock_ui/shared/theme/stoplight_colors.dart'; import 'package:tatlock_ui/shared/widgets/forecast_widget.dart'; @@ -25,6 +26,7 @@ class DashboardContent extends ConsumerStatefulWidget { class _DashboardContentState extends ConsumerState { Timer? _systemStatsTimer; Timer? _environmentTimer; + Timer? _newsTimer; /// Tracks if we've logged the environment API user (log once per session) static bool _hasLoggedEnvUser = false; @@ -46,12 +48,18 @@ class _DashboardContentState extends ConsumerState { const Duration(hours: 1), (_) => ref.invalidate(environmentProvider), ); + // Refresh news every 30 minutes + _newsTimer = Timer.periodic( + const Duration(minutes: 30), + (_) => ref.invalidate(newsProvider), + ); } @override void dispose() { _systemStatsTimer?.cancel(); _environmentTimer?.cancel(); + _newsTimer?.cancel(); super.dispose(); } @@ -60,6 +68,7 @@ class _DashboardContentState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; final systemStatsAsync = ref.watch(systemStatsProvider); final environmentAsync = ref.watch(environmentProvider); + final newsAsync = ref.watch(newsProvider); return ListView( padding: const EdgeInsets.all(16), @@ -100,6 +109,14 @@ class _DashboardContentState extends ConsumerState { ), const SizedBox(height: 16), + // News Ticker - full width + newsAsync.when( + data: (newsData) => NewsTickerWidget(newsData: newsData), + loading: () => const NewsTickerWidget(), + error: (error, stack) => const NewsTickerWidget(), + ), + const SizedBox(height: 16), + // System Stats - Gauges _SectionHeader(title: 'System Stats', icon: Icons.monitor_heart), const SizedBox(height: 8), diff --git a/lib/shared/widgets/air_quality_widget.dart b/lib/shared/widgets/air_quality_widget.dart index 5f46e64..f3d9eb1 100644 --- a/lib/shared/widgets/air_quality_widget.dart +++ b/lib/shared/widgets/air_quality_widget.dart @@ -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 diff --git a/lib/shared/widgets/news_ticker_widget.dart b/lib/shared/widgets/news_ticker_widget.dart new file mode 100644 index 0000000..0e4dece --- /dev/null +++ b/lib/shared/widgets/news_ticker_widget.dart @@ -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 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), + ], + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/shared/widgets/weather_widget.dart b/lib/shared/widgets/weather_widget.dart index 83e7b12..e02f5ee 100644 --- a/lib/shared/widgets/weather_widget.dart +++ b/lib/shared/widgets/weather_widget.dart @@ -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 diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index a010639..cb221ca 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -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'; diff --git a/pubspec.yaml b/pubspec.yaml index 3b26086..eb690ab 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.5.9+1 +version: 1.6.0+1 environment: sdk: ^3.10.4