From 285d5829ca78021dd80853abcb096f6f670d6cfd Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 04:57:39 +0100 Subject: [PATCH 01/59] feat(l10n): add new analytics localization strings - Add time frame labels (day, week, month, year) - Add strings for "no data available" and "vs previous period" - Include new descriptions for analytics events --- lib/l10n/arb/app_ar.arb | 24 ++++++++++++++++++++++++ lib/l10n/arb/app_en.arb | 24 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index e6013b26..0c5b0284 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -3344,5 +3344,29 @@ "analyticsEventSourceFilterUsedDescription": "تتبع عندما يطبق المستخدم مرشح مصادر محفوظ.", "@analyticsEventSourceFilterUsedDescription": { "description": "وصف لحدث استخدام مرشح مصادر" + }, + "timeFrameDay": "24س", + "@timeFrameDay": { + "description": "Label for 24 hour time frame toggle" + }, + "timeFrameWeek": "7ي", + "@timeFrameWeek": { + "description": "Label for 7 day time frame toggle" + }, + "timeFrameMonth": "30ي", + "@timeFrameMonth": { + "description": "Label for 30 day time frame toggle" + }, + "timeFrameYear": "سنة", + "@timeFrameYear": { + "description": "Label for 1 year time frame toggle" + }, + "noDataAvailable": "لا توجد بيانات", + "@noDataAvailable": { + "description": "Message displayed when analytics data is missing" + }, + "vsPreviousPeriod": "مقارنة بالفترة السابقة", + "@vsPreviousPeriod": { + "description": "Label indicating the trend comparison context" } } \ No newline at end of file diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index b9e14336..948a5fae 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -3340,5 +3340,29 @@ "analyticsEventSourceFilterUsedDescription": "Track when a user applies a saved source filter.", "@analyticsEventSourceFilterUsedDescription": { "description": "Description for the Source Filter Usage analytics event" + }, + "timeFrameDay": "24H", + "@timeFrameDay": { + "description": "Label for 24 hour time frame toggle" + }, + "timeFrameWeek": "7D", + "@timeFrameWeek": { + "description": "Label for 7 day time frame toggle" + }, + "timeFrameMonth": "30D", + "@timeFrameMonth": { + "description": "Label for 30 day time frame toggle" + }, + "timeFrameYear": "1Y", + "@timeFrameYear": { + "description": "Label for 1 year time frame toggle" + }, + "noDataAvailable": "No data available", + "@noDataAvailable": { + "description": "Message displayed when analytics data is missing" + }, + "vsPreviousPeriod": "vs previous period", + "@vsPreviousPeriod": { + "description": "Label indicating the trend comparison context" } } \ No newline at end of file From a447baab85ef81e0b944b7d4f2383bed4c9e4790 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 04:58:01 +0100 Subject: [PATCH 02/59] build(serialization): sync --- lib/l10n/app_localizations.dart | 36 ++++++++++++++++++++++++++++++ lib/l10n/app_localizations_ar.dart | 18 +++++++++++++++ lib/l10n/app_localizations_en.dart | 18 +++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index e387b2a9..125ef0fc 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4849,6 +4849,42 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Track when a user applies a saved source filter.'** String get analyticsEventSourceFilterUsedDescription; + + /// Label for 24 hour time frame toggle + /// + /// In en, this message translates to: + /// **'24H'** + String get timeFrameDay; + + /// Label for 7 day time frame toggle + /// + /// In en, this message translates to: + /// **'7D'** + String get timeFrameWeek; + + /// Label for 30 day time frame toggle + /// + /// In en, this message translates to: + /// **'30D'** + String get timeFrameMonth; + + /// Label for 1 year time frame toggle + /// + /// In en, this message translates to: + /// **'1Y'** + String get timeFrameYear; + + /// Message displayed when analytics data is missing + /// + /// In en, this message translates to: + /// **'No data available'** + String get noDataAvailable; + + /// Label indicating the trend comparison context + /// + /// In en, this message translates to: + /// **'vs previous period'** + String get vsPreviousPeriod; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index a10b849b..9edb424d 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -2668,4 +2668,22 @@ class AppLocalizationsAr extends AppLocalizations { @override String get analyticsEventSourceFilterUsedDescription => 'تتبع عندما يطبق المستخدم مرشح مصادر محفوظ.'; + + @override + String get timeFrameDay => '24س'; + + @override + String get timeFrameWeek => '7ي'; + + @override + String get timeFrameMonth => '30ي'; + + @override + String get timeFrameYear => 'سنة'; + + @override + String get noDataAvailable => 'لا توجد بيانات'; + + @override + String get vsPreviousPeriod => 'مقارنة بالفترة السابقة'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 1c598751..217bc0dd 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2678,4 +2678,22 @@ class AppLocalizationsEn extends AppLocalizations { @override String get analyticsEventSourceFilterUsedDescription => 'Track when a user applies a saved source filter.'; + + @override + String get timeFrameDay => '24H'; + + @override + String get timeFrameWeek => '7D'; + + @override + String get timeFrameMonth => '30D'; + + @override + String get timeFrameYear => '1Y'; + + @override + String get noDataAvailable => 'No data available'; + + @override + String get vsPreviousPeriod => 'vs previous period'; } From 3b26fc960ab2b9412edd007b169a249fd4d75529 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 04:59:05 +0100 Subject: [PATCH 03/59] feat(analytics): implement analytics service and data repositories - Add KPI cards, chart cards, and ranked list cards data repositories - Implement AnalyticsService using new data repositories - Update bootstrap process to include new services and repositories - Add fixtures data for demo environment --- lib/bootstrap.dart | 59 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 95a3381e..6bbd89bd 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -13,6 +13,7 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/app/app.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/app/config/config.dart' as app_config; import 'package:flutter_news_app_web_dashboard_full_source_code/bloc_observer.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/services/analytics_service.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/services/pending_deletions_service.dart'; import 'package:http_client/http_client.dart'; import 'package:kv_storage_shared_preferences/kv_storage_shared_preferences.dart'; @@ -67,6 +68,9 @@ Future bootstrap( DataClient engagementsClient; DataClient reportsClient; DataClient appReviewsClient; + DataClient kpiCardsClient; + DataClient chartCardsClient; + DataClient rankedListCardsClient; if (appConfig.environment == app_config.AppEnvironment.demo) { headlinesClient = DataInMemory( @@ -142,6 +146,24 @@ Future bootstrap( initialData: getAppReviewsFixturesData(), logger: Logger('DataInMemory'), ); + kpiCardsClient = DataInMemory( + toJson: (i) => i.toJson(), + getId: (i) => i.id.name, + initialData: getKpiCardsFixturesData(), + logger: Logger('DataInMemory'), + ); + chartCardsClient = DataInMemory( + toJson: (i) => i.toJson(), + getId: (i) => i.id.name, + initialData: getChartCardsFixturesData(), + logger: Logger('DataInMemory'), + ); + rankedListCardsClient = DataInMemory( + toJson: (i) => i.toJson(), + getId: (i) => i.id.name, + initialData: getRankedListCardsFixturesData(), + logger: Logger('DataInMemory'), + ); } else { headlinesClient = DataApi( httpClient: httpClient!, @@ -228,6 +250,27 @@ Future bootstrap( toJson: (appReview) => appReview.toJson(), logger: Logger('DataApi'), ); + kpiCardsClient = DataApi( + httpClient: httpClient, + modelName: 'kpi_card', + fromJson: KpiCardData.fromJson, + toJson: (item) => item.toJson(), + logger: Logger('DataApi'), + ); + chartCardsClient = DataApi( + httpClient: httpClient, + modelName: 'chart_card', + fromJson: ChartCardData.fromJson, + toJson: (item) => item.toJson(), + logger: Logger('DataApi'), + ); + rankedListCardsClient = DataApi( + httpClient: httpClient, + modelName: 'ranked_list_card', + fromJson: RankedListCardData.fromJson, + toJson: (item) => item.toJson(), + logger: Logger('DataApi'), + ); } pendingDeletionsService = PendingDeletionsServiceImpl( @@ -265,6 +308,21 @@ Future bootstrap( final appReviewsRepository = DataRepository( dataClient: appReviewsClient, ); + final kpiCardsRepository = DataRepository( + dataClient: kpiCardsClient, + ); + final chartCardsRepository = DataRepository( + dataClient: chartCardsClient, + ); + final rankedListCardsRepository = DataRepository( + dataClient: rankedListCardsClient, + ); + + final analyticsService = AnalyticsService( + kpiRepository: kpiCardsRepository, + chartRepository: chartCardsRepository, + rankedListRepository: rankedListCardsRepository, + ); return App( authenticationRepository: authenticationRepository, @@ -280,6 +338,7 @@ Future bootstrap( engagementsRepository: engagementsRepository, reportsRepository: reportsRepository, appReviewsRepository: appReviewsRepository, + analyticsService: analyticsService, storageService: kvStorage, environment: environment, pendingDeletionsService: pendingDeletionsService, From de4d716b5c710709ec8b1028628851c0f4ae35e8 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 05:00:19 +0100 Subject: [PATCH 04/59] feat(analytics): add analytics service to app providers - Add AnalyticsService to App class constructor and properties - Provide AnalyticsService through RepositoryProvider in app build method - Remove unused imports related to pending deletions and updates services --- lib/app/view/app.dart | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 736ffa56..9523ea75 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -18,10 +18,7 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/content_manageme import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/sources_filter/sources_filter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/topics_filter/topics_filter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; -// import 'package:flutter_news_app_web_dashboard_full_source_code/overview/bloc/overview_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/router/router.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/shared/services/pending_deletions_service.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/shared/services/pending_updates_service.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/shared.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/bloc/user_filter/user_filter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/bloc/user_management_bloc.dart'; @@ -46,6 +43,7 @@ class App extends StatelessWidget { required DataRepository engagementsRepository, required DataRepository reportsRepository, required DataRepository appReviewsRepository, + required AnalyticsService analyticsService, required KVStorageService storageService, required AppEnvironment environment, required PendingDeletionsService pendingDeletionsService, @@ -64,6 +62,7 @@ class App extends StatelessWidget { _engagementsRepository = engagementsRepository, _reportsRepository = reportsRepository, _appReviewsRepository = appReviewsRepository, + _analyticsService = analyticsService, _environment = environment, _pendingDeletionsService = pendingDeletionsService; @@ -81,6 +80,7 @@ class App extends StatelessWidget { final DataRepository _engagementsRepository; final DataRepository _reportsRepository; final DataRepository _appReviewsRepository; + final AnalyticsService _analyticsService; final KVStorageService _kvStorageService; final AppEnvironment _environment; @@ -104,6 +104,7 @@ class App extends StatelessWidget { RepositoryProvider.value(value: _engagementsRepository), RepositoryProvider.value(value: _reportsRepository), RepositoryProvider.value(value: _appReviewsRepository), + RepositoryProvider.value(value: _analyticsService), RepositoryProvider.value(value: _kvStorageService), RepositoryProvider( create: (context) => const ThrottledFetchingService(), @@ -158,15 +159,7 @@ class App extends StatelessWidget { sourcesFilterBloc: context.read(), pendingDeletionsService: context.read(), ), - ), - // BlocProvider( - // create: (context) => OverviewBloc( - // headlinesRepository: context.read>(), - // topicsRepository: context.read>(), - // sourcesRepository: context.read>(), - // ), - // ), - // The UserFilterBloc is provided here to be available for both the + ), // The UserFilterBloc is provided here to be available for both the // UserManagementBloc and the UI components. BlocProvider(create: (_) => UserFilterBloc()), BlocProvider( From 110bce463a070c298ed770965cf463e166faf5d7 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 05:01:05 +0100 Subject: [PATCH 05/59] feat(shared): add AnalyticsService for managing analytics data - Implement a service responsible for fetching, caching, and managing analytics data - Provide in-memory caching for fetched data to prevent redundant network calls - Implement request deduplication to merge simultaneous requests for the same resource - Support fetching KPI, Chart, and Ranked List card data with cache bypass option - Include method to clear all cached data --- lib/shared/services/analytics_service.dart | 134 +++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 lib/shared/services/analytics_service.dart diff --git a/lib/shared/services/analytics_service.dart b/lib/shared/services/analytics_service.dart new file mode 100644 index 00000000..fc829cc0 --- /dev/null +++ b/lib/shared/services/analytics_service.dart @@ -0,0 +1,134 @@ +import 'dart:async'; + +import 'package:core/core.dart'; +import 'package:data_repository/data_repository.dart'; + +/// {@template analytics_service} +/// A service responsible for fetching, caching, and managing analytics data. +/// +/// This service acts as the "Single Source of Truth" for analytics within the +/// dashboard session. It implements: +/// 1. **In-Memory Caching:** Stores fetched data to prevent redundant network +/// calls when navigating between tabs or pages. +/// 2. **Request Deduplication:** Merges simultaneous requests for the same +/// resource into a single network call. +/// {@endtemplate} +class AnalyticsService { + /// {@macro analytics_service} + AnalyticsService({ + required DataRepository kpiRepository, + required DataRepository chartRepository, + required DataRepository rankedListRepository, + }) : _kpiRepository = kpiRepository, + _chartRepository = chartRepository, + _rankedListRepository = rankedListRepository; + + final DataRepository _kpiRepository; + final DataRepository _chartRepository; + final DataRepository _rankedListRepository; + + // --- Caches --- + final _kpiCache = {}; + final _chartCache = {}; + final _rankedListCache = {}; + + // --- In-Flight Requests (Deduplication) --- + final _kpiInFlight = >{}; + final _chartInFlight = >{}; + final _rankedListInFlight = + >{}; + + /// Fetches a KPI card by its [id]. + /// + /// If [forceRefresh] is true, bypasses the cache. + Future getKpi( + KpiCardId id, { + bool forceRefresh = false, + }) async { + if (!forceRefresh && _kpiCache.containsKey(id)) { + return _kpiCache[id]!; + } + + if (_kpiInFlight.containsKey(id)) { + return _kpiInFlight[id]!; + } + + final future = _kpiRepository.read(id: id.name).then((data) { + _kpiCache[id] = data; + return data; + }); + + _kpiInFlight[id] = future; + + try { + return await future; + } finally { + _kpiInFlight.remove(id); + } + } + + /// Fetches a Chart card by its [id]. + /// + /// If [forceRefresh] is true, bypasses the cache. + Future getChart( + ChartCardId id, { + bool forceRefresh = false, + }) async { + if (!forceRefresh && _chartCache.containsKey(id)) { + return _chartCache[id]!; + } + + if (_chartInFlight.containsKey(id)) { + return _chartInFlight[id]!; + } + + final future = _chartRepository.read(id: id.name).then((data) { + _chartCache[id] = data; + return data; + }); + + _chartInFlight[id] = future; + + try { + return await future; + } finally { + _chartInFlight.remove(id); + } + } + + /// Fetches a Ranked List card by its [id]. + /// + /// If [forceRefresh] is true, bypasses the cache. + Future getRankedList( + RankedListCardId id, { + bool forceRefresh = false, + }) async { + if (!forceRefresh && _rankedListCache.containsKey(id)) { + return _rankedListCache[id]!; + } + + if (_rankedListInFlight.containsKey(id)) { + return _rankedListInFlight[id]!; + } + + final future = _rankedListRepository.read(id: id.name).then((data) { + _rankedListCache[id] = data; + return data; + }); + + _rankedListInFlight[id] = future; + + try { + return await future; + } finally { + _rankedListInFlight.remove(id); + } + } + + /// Clears all cached data. + void clearCache() { + _kpiCache.clear(); + _chartCache.clear(); + _rankedListCache.clear(); + } +} From 4824e6af962e8ba6dd225f787aa877519c20d26f Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 05:01:21 +0100 Subject: [PATCH 06/59] feat(ui_kit): add AnalyticsCardShell widget - Create a consistent container for analytics cards - Include standard styling, padding, and a header with title and optional action widgets - Use in KPI, Chart, and Ranked List cards --- .../analytics/analytics_card_shell.dart | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 lib/shared/widgets/analytics/analytics_card_shell.dart diff --git a/lib/shared/widgets/analytics/analytics_card_shell.dart b/lib/shared/widgets/analytics/analytics_card_shell.dart new file mode 100644 index 00000000..543edfa3 --- /dev/null +++ b/lib/shared/widgets/analytics/analytics_card_shell.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:ui_kit/ui_kit.dart'; + +/// {@template analytics_card_shell} +/// A consistent container for all analytics cards (KPI, Chart, Ranked List). +/// +/// Provides standard styling, padding, and a header with a title and optional +/// action widgets (like time frame toggles). +/// {@endtemplate} +class AnalyticsCardShell extends StatelessWidget { + /// {@macro analytics_card_shell} + const AnalyticsCardShell({ + required this.title, + required this.child, + this.action, + super.key, + }); + + /// The title of the card. + final String title; + + /// An optional action widget to display in the header (e.g., toggles). + final Widget? action; + + /// The main content of the card. + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppSpacing.md), + side: BorderSide(color: theme.colorScheme.outlineVariant), + ), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (action != null) ...[ + const SizedBox(width: AppSpacing.sm), + action!, + ], + ], + ), + const SizedBox(height: AppSpacing.md), + Expanded(child: child), + ], + ), + ), + ); + } +} From c3084778161725af4ce80a04507cdab3187aa0c0 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 05:02:31 +0100 Subject: [PATCH 07/59] feat(analytics): add analytics card slot widget - Create a new widget `AnalyticsCardSlot` to manage multiple analytics cards - Implement functionality to switch between different analytics cards - Integrate with `AnalyticsService` to fetch and display KPI and chart data - Add navigation dots for card switching when multiple cards are present --- .../analytics/analytics_card_slot.dart | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 lib/shared/widgets/analytics/analytics_card_slot.dart diff --git a/lib/shared/widgets/analytics/analytics_card_slot.dart b/lib/shared/widgets/analytics/analytics_card_slot.dart new file mode 100644 index 00000000..8421b642 --- /dev/null +++ b/lib/shared/widgets/analytics/analytics_card_slot.dart @@ -0,0 +1,114 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/services/analytics_service.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/analytics/chart_card.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/analytics/kpi_card.dart'; +import 'package:ui_kit/ui_kit.dart'; + +/// {@template analytics_card_slot} +/// A widget that manages a slot containing multiple analytics cards, allowing +/// users to switch between them. +/// +/// This widget is generic and can handle either [KpiCardId] or [ChartCardId]. +/// It fetches data using the [AnalyticsService] and displays the appropriate +/// card widget. +/// {@endtemplate} +class AnalyticsCardSlot extends StatefulWidget { + /// {@macro analytics_card_slot} + const AnalyticsCardSlot({ + required this.cardIds, + super.key, + }) : assert(cardIds.length > 0, 'Must provide at least one card ID'); + + /// The list of card IDs available in this slot. + /// Must be a list of [KpiCardId] or [ChartCardId]. + final List cardIds; + + @override + State> createState() => _AnalyticsCardSlotState(); +} + +class _AnalyticsCardSlotState + extends State> { + int _currentIndex = 0; + + @override + Widget build(BuildContext context) { + final currentId = widget.cardIds[_currentIndex]; + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Card Content + Expanded( + child: _buildCardContent(currentId), + ), + const SizedBox(height: AppSpacing.sm), + // Navigation Dots (only if multiple cards) + if (widget.cardIds.length > 1) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(widget.cardIds.length, (index) { + final isSelected = index == _currentIndex; + return GestureDetector( + onTap: () => setState(() => _currentIndex = index), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 4), + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected + ? theme.colorScheme.primary + : theme.colorScheme.surfaceContainerHighest, + ), + ), + ); + }), + ), + ], + ); + } + + Widget _buildCardContent(T id) { + final analyticsService = context.read(); + + if (id is KpiCardId) { + return FutureBuilder( + future: analyticsService.getKpi(id), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } + if (snapshot.hasData) { + return KpiCard(data: snapshot.data!); + } + return const SizedBox.shrink(); + }, + ); + } else if (id is ChartCardId) { + return FutureBuilder( + future: analyticsService.getChart(id), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } + if (snapshot.hasData) { + return ChartCard(data: snapshot.data!); + } + return const SizedBox.shrink(); + }, + ); + } + + return const Center(child: Text('Unsupported Card Type')); + } +} From ef6d2554cfaf848ae2db3057220897187e942c23 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 05:02:48 +0100 Subject: [PATCH 08/59] feat(analytics): add chart card widget - Implement ChartCard widget to display bar or line charts with time frame toggles - Add ChartCardData model to hold chart data - Include time frame toggle functionality using SegmentedButton - Ensure responsive design with LayoutBuilder - Add localization support for time frame labels --- lib/shared/widgets/analytics/chart_card.dart | 142 +++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 lib/shared/widgets/analytics/chart_card.dart diff --git a/lib/shared/widgets/analytics/chart_card.dart b/lib/shared/widgets/analytics/chart_card.dart new file mode 100644 index 00000000..2b5ac69c --- /dev/null +++ b/lib/shared/widgets/analytics/chart_card.dart @@ -0,0 +1,142 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/analytics/analytics_card_shell.dart'; + +/// {@template chart_card} +/// A widget that displays a chart (Bar or Line) with time frame toggles. +/// +/// Note: Since no external chart library is available, this uses a custom +/// visual representation using standard Flutter widgets. +/// {@endtemplate} +class ChartCard extends StatefulWidget { + /// {@macro chart_card} + const ChartCard({ + required this.data, + super.key, + }); + + /// The data object containing chart points for all time frames. + final ChartCardData data; + + @override + State createState() => _ChartCardState(); +} + +class _ChartCardState extends State { + ChartTimeFrame _selectedTimeFrame = ChartTimeFrame.week; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + final currentPoints = widget.data.timeFrames[_selectedTimeFrame]; + + if (currentPoints == null || currentPoints.isEmpty) { + return AnalyticsCardShell( + title: widget.data.label, + child: Center(child: Text(l10n.noDataAvailable)), + ); + } + + return AnalyticsCardShell( + title: widget.data.label, + action: _TimeFrameToggle( + selected: _selectedTimeFrame, + onChanged: (value) => setState(() => _selectedTimeFrame = value), + l10n: l10n, + ), + child: LayoutBuilder( + builder: (context, constraints) { + // Find max value to normalize heights + final maxValue = currentPoints + .map((e) => e.value) + .reduce((a, b) => a > b ? a : b) + .toDouble(); + + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: currentPoints.map((point) { + final heightFactor = + maxValue > 0 ? (point.value / maxValue) : 0.0; + // Ensure a minimum height so the bar is visible + final barHeight = (constraints.maxHeight - 20) * heightFactor; + + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // Tooltip behavior could be added here + Tooltip( + message: '${point.label}: ${point.value}', + child: Container( + height: barHeight < 4 ? 4 : barHeight, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + const SizedBox(height: 8), + Text( + point.label ?? '', + style: Theme.of(context).textTheme.labelSmall, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ], + ), + ), + ); + }).toList(), + ); + }, + ), + ); + } +} + +class _TimeFrameToggle extends StatelessWidget { + const _TimeFrameToggle({ + required this.selected, + required this.onChanged, + required this.l10n, + }); + + final ChartTimeFrame selected; + final ValueChanged onChanged; + final AppLocalizations l10n; + + @override + Widget build(BuildContext context) { + return SegmentedButton( + segments: [ + ButtonSegment( + value: ChartTimeFrame.week, + label: Text(l10n.timeFrameWeek), + ), + ButtonSegment( + value: ChartTimeFrame.month, + label: Text(l10n.timeFrameMonth), + ), + ButtonSegment( + value: ChartTimeFrame.year, + label: Text(l10n.timeFrameYear), + ), + ], + selected: {selected}, + onSelectionChanged: (Set newSelection) { + onChanged(newSelection.first); + }, + showSelectedIcon: false, + style: ButtonStyle( + visualDensity: VisualDensity.compact, + padding: WidgetStateProperty.all(EdgeInsets.zero), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ); + } +} From aaa05fd9583407f11a89ae9387b5e27b207f648d Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 05:03:22 +0100 Subject: [PATCH 09/59] feat(analytics): add KPI card widget - Implement KpiCard StatefulWidget to display Key Performance Indicator - Add _KpiCardState class to manage time frame selection and UI rendering - Create _TimeFrameToggle widget for switching between different time frames - Implement localization support for time frame labels and no data message - Add trend indication with dynamic icon and color based on positive/negative trend - Format numbers for display in the KPI value --- lib/shared/widgets/analytics/kpi_card.dart | 141 +++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 lib/shared/widgets/analytics/kpi_card.dart diff --git a/lib/shared/widgets/analytics/kpi_card.dart b/lib/shared/widgets/analytics/kpi_card.dart new file mode 100644 index 00000000..d419a24e --- /dev/null +++ b/lib/shared/widgets/analytics/kpi_card.dart @@ -0,0 +1,141 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/analytics/analytics_card_shell.dart'; +import 'package:ui_kit/ui_kit.dart'; + +/// {@template kpi_card} +/// A widget that displays a Key Performance Indicator (KPI) with a value, +/// trend, and time frame toggles. +/// {@endtemplate} +class KpiCard extends StatefulWidget { + /// {@macro kpi_card} + const KpiCard({ + required this.data, + super.key, + }); + + /// The data object containing values for all time frames. + final KpiCardData data; + + @override + State createState() => _KpiCardState(); +} + +class _KpiCardState extends State { + // Default to 'week' as a reasonable starting point. + KpiTimeFrame _selectedTimeFrame = KpiTimeFrame.week; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = AppLocalizationsX(context).l10n; + final currentData = widget.data.timeFrames[_selectedTimeFrame]; + + // Fallback if data for the selected time frame is missing. + if (currentData == null) { + return AnalyticsCardShell( + title: widget.data.label, + child: Center(child: Text(l10n.noDataAvailable)), + ); + } + + final isPositiveTrend = !currentData.trend.startsWith('-'); + final trendColor = + isPositiveTrend ? theme.colorScheme.primary : theme.colorScheme.error; + final trendIcon = + isPositiveTrend ? Icons.arrow_upward : Icons.arrow_downward; + + return AnalyticsCardShell( + title: widget.data.label, + action: _TimeFrameToggle( + selected: _selectedTimeFrame, + onChanged: (value) => setState(() => _selectedTimeFrame = value), + l10n: l10n, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + // Format numbers nicely (e.g., 1,234). + // For simplicity, using toString here, but NumberFormat is better. + currentData.value.toString(), + style: theme.textTheme.displayMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + Icon(trendIcon, size: 16, color: trendColor), + const SizedBox(width: 4), + Text( + currentData.trend, + style: theme.textTheme.bodyMedium?.copyWith( + color: trendColor, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: AppSpacing.xs), + Text( + l10n.vsPreviousPeriod, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ], + ), + ); + } +} + +class _TimeFrameToggle extends StatelessWidget { + const _TimeFrameToggle({ + required this.selected, + required this.onChanged, + required this.l10n, + }); + + final KpiTimeFrame selected; + final ValueChanged onChanged; + final AppLocalizations l10n; + + @override + Widget build(BuildContext context) { + return SegmentedButton( + segments: [ + ButtonSegment( + value: KpiTimeFrame.day, + label: Text(l10n.timeFrameDay), + ), + ButtonSegment( + value: KpiTimeFrame.week, + label: Text(l10n.timeFrameWeek), + ), + ButtonSegment( + value: KpiTimeFrame.month, + label: Text(l10n.timeFrameMonth), + ), + ButtonSegment( + value: KpiTimeFrame.year, + label: Text(l10n.timeFrameYear), + ), + ], + selected: {selected}, + onSelectionChanged: (Set newSelection) { + onChanged(newSelection.first); + }, + showSelectedIcon: false, + style: ButtonStyle( + visualDensity: VisualDensity.compact, + padding: WidgetStateProperty.all(EdgeInsets.zero), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ); + } +} From caab043480c2b8e0e10f686573cc85251a559a2f Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 05:03:49 +0100 Subject: [PATCH 10/59] chore: barrels --- lib/shared/services/services.dart | 3 +++ lib/shared/widgets/analytics/analytics.dart | 5 +++++ lib/shared/widgets/widgets.dart | 1 + 3 files changed, 9 insertions(+) create mode 100644 lib/shared/widgets/analytics/analytics.dart diff --git a/lib/shared/services/services.dart b/lib/shared/services/services.dart index 05d9ad0e..23e56587 100644 --- a/lib/shared/services/services.dart +++ b/lib/shared/services/services.dart @@ -1 +1,4 @@ +export 'analytics_service.dart'; +export 'pending_deletions_service.dart'; +export 'pending_updates_service.dart'; export 'throttled_fetching_service.dart'; diff --git a/lib/shared/widgets/analytics/analytics.dart b/lib/shared/widgets/analytics/analytics.dart new file mode 100644 index 00000000..b60c000a --- /dev/null +++ b/lib/shared/widgets/analytics/analytics.dart @@ -0,0 +1,5 @@ +export 'analytics_card_shell.dart'; +export 'analytics_card_slot.dart'; +export 'chart_card.dart'; +export 'kpi_card.dart'; +export 'ranked_list_card.dart'; diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index e3b9f66d..d06f1990 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -1,4 +1,5 @@ export 'about_icon.dart'; +export 'analytics/analytics.dart'; export 'confirmation_dialog.dart'; export 'searchable_selection_input.dart'; export 'selection_page/selection_page.dart'; From 87887668b227e26d8a925e683e68a8ee1aceceb3 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 05:04:04 +0100 Subject: [PATCH 11/59] feat(analytics): add ranked list card widget - Implement RankedListCard StatefulWidget for displaying ranked lists - Add _RankedListCardState with time frame toggle functionality - Create _TimeFrameToggle widget for selecting time frames - Integrate AnalyticsCardShell for consistent styling - Support localization for various time frame labels --- .../widgets/analytics/ranked_list_card.dart | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 lib/shared/widgets/analytics/ranked_list_card.dart diff --git a/lib/shared/widgets/analytics/ranked_list_card.dart b/lib/shared/widgets/analytics/ranked_list_card.dart new file mode 100644 index 00000000..eb0f71cc --- /dev/null +++ b/lib/shared/widgets/analytics/ranked_list_card.dart @@ -0,0 +1,130 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/analytics/analytics_card_shell.dart'; + +/// {@template ranked_list_card} +/// A widget that displays a ranked list of items (e.g., Top 5 Headlines) +/// with time frame toggles. +/// {@endtemplate} +class RankedListCard extends StatefulWidget { + /// {@macro ranked_list_card} + const RankedListCard({ + required this.data, + super.key, + }); + + /// The data object containing ranked lists for all time frames. + final RankedListCardData data; + + @override + State createState() => _RankedListCardState(); +} + +class _RankedListCardState extends State { + RankedListTimeFrame _selectedTimeFrame = RankedListTimeFrame.week; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + final theme = Theme.of(context); + final currentList = widget.data.timeFrames[_selectedTimeFrame]; + + if (currentList == null || currentList.isEmpty) { + return AnalyticsCardShell( + title: widget.data.label, + child: Center(child: Text(l10n.noDataAvailable)), + ); + } + + return AnalyticsCardShell( + title: widget.data.label, + action: _TimeFrameToggle( + selected: _selectedTimeFrame, + onChanged: (value) => setState(() => _selectedTimeFrame = value), + l10n: l10n, + ), + child: ListView.separated( + itemCount: currentList.length, + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, index) { + final item = currentList[index]; + return ListTile( + contentPadding: EdgeInsets.zero, + leading: CircleAvatar( + backgroundColor: theme.colorScheme.primaryContainer, + foregroundColor: theme.colorScheme.onPrimaryContainer, + radius: 12, + child: Text( + '${index + 1}', + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + title: Text( + item.displayTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium, + ), + trailing: Text( + item.metricValue.toString(), + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + ); + }, + ), + ); + } +} + +class _TimeFrameToggle extends StatelessWidget { + const _TimeFrameToggle({ + required this.selected, + required this.onChanged, + required this.l10n, + }); + + final RankedListTimeFrame selected; + final ValueChanged onChanged; + final AppLocalizations l10n; + + @override + Widget build(BuildContext context) { + return SegmentedButton( + segments: [ + ButtonSegment( + value: RankedListTimeFrame.day, + label: Text(l10n.timeFrameDay), + ), + ButtonSegment( + value: RankedListTimeFrame.week, + label: Text(l10n.timeFrameWeek), + ), + ButtonSegment( + value: RankedListTimeFrame.month, + label: Text(l10n.timeFrameMonth), + ), + ButtonSegment( + value: RankedListTimeFrame.year, + label: Text(l10n.timeFrameYear), + ), + ], + selected: {selected}, + onSelectionChanged: (Set newSelection) { + onChanged(newSelection.first); + }, + showSelectedIcon: false, + style: ButtonStyle( + visualDensity: VisualDensity.compact, + padding: WidgetStateProperty.all(EdgeInsets.zero), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ); + } +} From 118ed278d0a202f3ca7694b40f549d50c28befe3 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 05:08:08 +0100 Subject: [PATCH 12/59] feat(analytics): add ranked list card support to AnalyticsCardSlot - Updated AnalyticsCardSlot to support RankedListCardId - Added logic to fetch and display RankedListCard data - Improved type documentation to include new card type support - Refactored UI layout for better readability --- .../analytics/analytics_card_slot.dart | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/lib/shared/widgets/analytics/analytics_card_slot.dart b/lib/shared/widgets/analytics/analytics_card_slot.dart index 8421b642..1c3a0bfa 100644 --- a/lib/shared/widgets/analytics/analytics_card_slot.dart +++ b/lib/shared/widgets/analytics/analytics_card_slot.dart @@ -4,15 +4,16 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/services/analytics_service.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/analytics/chart_card.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/analytics/kpi_card.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/analytics/ranked_list_card.dart'; import 'package:ui_kit/ui_kit.dart'; /// {@template analytics_card_slot} /// A widget that manages a slot containing multiple analytics cards, allowing /// users to switch between them. /// -/// This widget is generic and can handle either [KpiCardId] or [ChartCardId]. -/// It fetches data using the [AnalyticsService] and displays the appropriate -/// card widget. +/// This widget is generic and can handle [KpiCardId], [ChartCardId], or +/// [RankedListCardId]. It fetches data using the [AnalyticsService] and +/// displays the appropriate card widget. /// {@endtemplate} class AnalyticsCardSlot extends StatefulWidget { /// {@macro analytics_card_slot} @@ -22,7 +23,7 @@ class AnalyticsCardSlot extends StatefulWidget { }) : assert(cardIds.length > 0, 'Must provide at least one card ID'); /// The list of card IDs available in this slot. - /// Must be a list of [KpiCardId] or [ChartCardId]. + /// Must be a list of [KpiCardId], [ChartCardId], or [RankedListCardId]. final List cardIds; @override @@ -45,9 +46,9 @@ class _AnalyticsCardSlotState Expanded( child: _buildCardContent(currentId), ), - const SizedBox(height: AppSpacing.sm), // Navigation Dots (only if multiple cards) - if (widget.cardIds.length > 1) + if (widget.cardIds.length > 1) ...[ + const SizedBox(height: AppSpacing.sm), Row( mainAxisAlignment: MainAxisAlignment.center, children: List.generate(widget.cardIds.length, (index) { @@ -68,6 +69,7 @@ class _AnalyticsCardSlotState ); }), ), + ], ], ); } @@ -107,6 +109,22 @@ class _AnalyticsCardSlotState return const SizedBox.shrink(); }, ); + } else if (id is RankedListCardId) { + return FutureBuilder( + future: analyticsService.getRankedList(id), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } + if (snapshot.hasData) { + return RankedListCard(data: snapshot.data!); + } + return const SizedBox.shrink(); + }, + ); } return const Center(child: Text('Unsupported Card Type')); From b8cf6806625cc38f5dd52223d72ca61106c1298d Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 05:08:31 +0100 Subject: [PATCH 13/59] feat(overview): add OverviewBloc to the app state - Import OverviewBloc from the overview/bloc/overview_bloc.dart file - Add BlocProvider for OverviewBloc in the app's state management --- lib/app/view/app.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 9523ea75..bf01897b 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -18,6 +18,7 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/content_manageme import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/sources_filter/sources_filter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/topics_filter/topics_filter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/overview/bloc/overview_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/router/router.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/shared.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/bloc/user_filter/user_filter_bloc.dart'; @@ -159,7 +160,11 @@ class App extends StatelessWidget { sourcesFilterBloc: context.read(), pendingDeletionsService: context.read(), ), - ), // The UserFilterBloc is provided here to be available for both the + ), + BlocProvider( + create: (context) => OverviewBloc(), + ), + // The UserFilterBloc is provided here to be available for both the // UserManagementBloc and the UI components. BlocProvider(create: (_) => UserFilterBloc()), BlocProvider( From d2a0a54e0457b0e5d545da5fb2124ea097ff9dc7 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 05:08:47 +0100 Subject: [PATCH 14/59] feat(user_management): add analytics dashboard to users page - Implement AnalyticsCardSlot widget for displaying KPI and chart cards - Add user-related analytics cards (total registered, new registrations, active users) - Include charts for user registrations over time, active users over time, and role distribution --- lib/user_management/view/users_page.dart | 35 ++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/lib/user_management/view/users_page.dart b/lib/user_management/view/users_page.dart index 653707fd..fb773851 100644 --- a/lib/user_management/view/users_page.dart +++ b/lib/user_management/view/users_page.dart @@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/extensions/app_user_role_ui.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/analytics/analytics_card_slot.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/bloc/user_filter/user_filter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/bloc/user_management_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/enums/authentication_filter.dart'; @@ -121,6 +122,40 @@ class _UsersPageState extends State { // Display the data table with users. return Column( children: [ + // Analytics Dashboard Strip + Padding( + padding: const EdgeInsets.only( + left: AppSpacing.sm, + right: AppSpacing.sm, + bottom: AppSpacing.md, + ), + child: SizedBox( + height: 200, + child: Row( + children: [ + Expanded( + child: AnalyticsCardSlot( + cardIds: const [ + KpiCardId.usersTotalRegistered, + KpiCardId.usersNewRegistrations, + KpiCardId.usersActiveUsers, + ], + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: AnalyticsCardSlot( + cardIds: const [ + ChartCardId.usersRegistrationsOverTime, + ChartCardId.usersActiveUsersOverTime, + ChartCardId.usersRoleDistribution, + ], + ), + ), + ], + ), + ), + ), // Show a linear progress indicator during subsequent loads/pagination. if (state.status == UserManagementStatus.loading && state.users.isNotEmpty) From 7b4a586210a2e73ee98fdcf644bd10ea961864ee Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 05:09:27 +0100 Subject: [PATCH 15/59] feat(overview): implement main dashboard layout and KPIs - Refactor OverviewPage from StatefulWidget to StatelessWidget - Add AppBar with localized title - Implement fixed grid layout for displaying KPIs, trends, and ranked lists - Use AnalyticsCardSlot for displaying different types of cards - Add spacing between elements using SizedBox --- lib/overview/view/overview_page.dart | 104 ++++++++++++++++++++++++--- 1 file changed, 95 insertions(+), 9 deletions(-) diff --git a/lib/overview/view/overview_page.dart b/lib/overview/view/overview_page.dart index e2f076d7..345b1ea8 100644 --- a/lib/overview/view/overview_page.dart +++ b/lib/overview/view/overview_page.dart @@ -1,22 +1,108 @@ +import 'package:core/core.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/analytics/analytics_card_slot.dart'; +import 'package:ui_kit/ui_kit.dart'; /// {@template overview_page} /// The main dashboard overview page, displaying key statistics and quick actions. +/// +/// This page uses a fixed grid layout to display high-level KPIs, trends, and +/// ranked lists, providing a "Mission Control" view of the application. /// {@endtemplate} -class OverviewPage extends StatefulWidget { +class OverviewPage extends StatelessWidget { /// {@macro overview_page} const OverviewPage({super.key}); - @override - State createState() => _OverviewPageState(); -} - -class _OverviewPageState extends State { @override Widget build(BuildContext context) { - // final l10n = AppLocalizationsX(context).l10n; - return const Scaffold( - body: Placeholder(), + final l10n = AppLocalizationsX(context).l10n; + + return Scaffold( + appBar: AppBar( + title: Text(l10n.overview), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Row 1: High-Level KPIs + SizedBox( + height: 160, + child: Row( + children: [ + Expanded( + child: AnalyticsCardSlot( + cardIds: const [KpiCardId.usersTotalRegistered], + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: AnalyticsCardSlot( + cardIds: const [KpiCardId.contentHeadlinesTotalViews], + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: AnalyticsCardSlot( + cardIds: const [KpiCardId.engagementsReportsPending], + ), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.lg), + + // Row 2: Primary Trends (Charts) + SizedBox( + height: 350, + child: Row( + children: [ + Expanded( + child: AnalyticsCardSlot( + cardIds: const [ChartCardId.usersRegistrationsOverTime], + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: AnalyticsCardSlot( + cardIds: const [ + ChartCardId.contentHeadlinesViewsOverTime, + ], + ), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.lg), + + // Row 3: Top Performers (Ranked Lists) + SizedBox( + height: 350, + child: Row( + children: [ + Expanded( + child: AnalyticsCardSlot( + cardIds: const [ + RankedListCardId.overviewHeadlinesMostViewed, + ], + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: AnalyticsCardSlot( + cardIds: const [ + RankedListCardId.overviewSourcesMostFollowed, + ], + ), + ), + ], + ), + ), + ], + ), + ), ); } } From 16b734fc350dbea11e5136cfc17f6300533b1f53 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 05:14:24 +0100 Subject: [PATCH 16/59] feat(analytics): add AnalyticsDashboardStrip widget - Create a new widget for displaying a standard "Dashboard Strip" configuration - Consists of two side-by-side AnalyticsCardSlot widgets for KPI and Chart cards - Ensure consistent layout and behavior across management pages --- lib/shared/widgets/analytics/analytics.dart | 1 + .../analytics/analytics_dashboard_strip.dart | 59 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 lib/shared/widgets/analytics/analytics_dashboard_strip.dart diff --git a/lib/shared/widgets/analytics/analytics.dart b/lib/shared/widgets/analytics/analytics.dart index b60c000a..fb1d992e 100644 --- a/lib/shared/widgets/analytics/analytics.dart +++ b/lib/shared/widgets/analytics/analytics.dart @@ -1,5 +1,6 @@ export 'analytics_card_shell.dart'; export 'analytics_card_slot.dart'; +export 'analytics_dashboard_strip.dart'; export 'chart_card.dart'; export 'kpi_card.dart'; export 'ranked_list_card.dart'; diff --git a/lib/shared/widgets/analytics/analytics_dashboard_strip.dart b/lib/shared/widgets/analytics/analytics_dashboard_strip.dart new file mode 100644 index 00000000..a7bab073 --- /dev/null +++ b/lib/shared/widgets/analytics/analytics_dashboard_strip.dart @@ -0,0 +1,59 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/analytics/analytics_card_slot.dart'; +import 'package:ui_kit/ui_kit.dart'; + +/// {@template analytics_dashboard_strip} +/// A reusable widget that displays the standard "Dashboard Strip" configuration +/// for management pages. +/// +/// It consists of two side-by-side [AnalyticsCardSlot]s: +/// - **Left Slot:** Displays a list of KPI cards. +/// - **Right Slot:** Displays a list of Chart cards. +/// +/// This ensures a consistent layout and behavior across Users, Content, and +/// Community management pages. +/// {@endtemplate} +class AnalyticsDashboardStrip extends StatelessWidget { + /// {@macro analytics_dashboard_strip} + const AnalyticsDashboardStrip({ + required this.kpiCards, + required this.chartCards, + super.key, + }); + + /// The list of KPI card IDs to display in the left slot. + final List kpiCards; + + /// The list of Chart card IDs to display in the right slot. + final List chartCards; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only( + left: AppSpacing.sm, + right: AppSpacing.sm, + bottom: AppSpacing.md, + ), + child: SizedBox( + height: 200, + child: Row( + children: [ + Expanded( + child: AnalyticsCardSlot( + cardIds: kpiCards, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: AnalyticsCardSlot( + cardIds: chartCards, + ), + ), + ], + ), + ), + ); + } +} From b0e012a4ad565d7cf14a78f4465a51ba5373701e Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 05:14:51 +0100 Subject: [PATCH 17/59] refactor(user_management): replace custom analytics dashboard layout with strip - Remove AnalyticsCardSlot usage for KPI and chart cards - Replace with AnalyticsDashboardStrip widget for better consistency - Simplify the code structure and improve readability --- lib/user_management/view/users_page.dart | 45 +++++++----------------- 1 file changed, 12 insertions(+), 33 deletions(-) diff --git a/lib/user_management/view/users_page.dart b/lib/user_management/view/users_page.dart index fb773851..e5824bfd 100644 --- a/lib/user_management/view/users_page.dart +++ b/lib/user_management/view/users_page.dart @@ -5,7 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/extensions/app_user_role_ui.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/analytics/analytics_card_slot.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/analytics/analytics_dashboard_strip.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/bloc/user_filter/user_filter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/bloc/user_management_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/enums/authentication_filter.dart'; @@ -123,38 +123,17 @@ class _UsersPageState extends State { return Column( children: [ // Analytics Dashboard Strip - Padding( - padding: const EdgeInsets.only( - left: AppSpacing.sm, - right: AppSpacing.sm, - bottom: AppSpacing.md, - ), - child: SizedBox( - height: 200, - child: Row( - children: [ - Expanded( - child: AnalyticsCardSlot( - cardIds: const [ - KpiCardId.usersTotalRegistered, - KpiCardId.usersNewRegistrations, - KpiCardId.usersActiveUsers, - ], - ), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: AnalyticsCardSlot( - cardIds: const [ - ChartCardId.usersRegistrationsOverTime, - ChartCardId.usersActiveUsersOverTime, - ChartCardId.usersRoleDistribution, - ], - ), - ), - ], - ), - ), + const AnalyticsDashboardStrip( + kpiCards: [ + KpiCardId.usersTotalRegistered, + KpiCardId.usersNewRegistrations, + KpiCardId.usersActiveUsers, + ], + chartCards: [ + ChartCardId.usersRegistrationsOverTime, + ChartCardId.usersActiveUsersOverTime, + ChartCardId.usersRoleDistribution, + ], ), // Show a linear progress indicator during subsequent loads/pagination. if (state.status == UserManagementStatus.loading && From 12f0e842c3bb4daa5bf8a6f598aaeb64b26ecf50 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 05:24:08 +0100 Subject: [PATCH 18/59] build(dependencies): add fl_chart dependency - Add fl_chart package to pubspec.yaml for creating charts and dashboards --- pubspec.lock | 8 ++++++++ pubspec.yaml | 1 + 2 files changed, 9 insertions(+) diff --git a/pubspec.lock b/pubspec.lock index 85a22687..92a74b98 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -210,6 +210,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: "7ca9a40f4eb85949190e54087be8b4d6ac09dc4c54238d782a34cf1f7c011de9" + url: "https://pub.dev" + source: hosted + version: "1.1.1" flex_color_scheme: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index ec526fd4..be7a212f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -50,6 +50,7 @@ dependencies: data_table_2: ^2.6.0 device_preview: ^1.2.0 equatable: ^2.0.7 + fl_chart: ^1.1.1 flex_color_scheme: ^8.3.0 flutter: sdk: flutter From d6abf8053d43bf7f7e388eabef5a6c7381970e52 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 05:24:33 +0100 Subject: [PATCH 19/59] feat(community_management): add analytics dashboard strip to app reviews page - Import AnalyticsDashboardStrip widget - Add AnalyticsDashboardStrip to the beginning of the Column in _AppReviewsPageState - Include KPI cards for total feedback, positive feedback, and store requests - Add chart cards for feedback over time, positive vs negative feedback, and store requests over time --- .../view/app_reviews_page.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/community_management/view/app_reviews_page.dart b/lib/community_management/view/app_reviews_page.dart index 241d099e..a3948d91 100644 --- a/lib/community_management/view/app_reviews_page.dart +++ b/lib/community_management/view/app_reviews_page.dart @@ -8,6 +8,7 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/community_manage import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/extensions/app_review_feedback_extension.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/analytics/analytics_dashboard_strip.dart'; import 'package:intl/intl.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -103,6 +104,19 @@ class _AppReviewsPageState extends State { return Column( children: [ + // Analytics Dashboard Strip + const AnalyticsDashboardStrip( + kpiCards: [ + KpiCardId.engagementsAppReviewsTotalFeedback, + KpiCardId.engagementsAppReviewsPositiveFeedback, + KpiCardId.engagementsAppReviewsStoreRequests, + ], + chartCards: [ + ChartCardId.engagementsAppReviewsFeedbackOverTime, + ChartCardId.engagementsAppReviewsPositiveVsNegative, + ChartCardId.engagementsAppReviewsStoreRequestsOverTime, + ], + ), if (state.appReviewsStatus == CommunityManagementStatus.loading && state.appReviews.isNotEmpty) const LinearProgressIndicator(), From 8cc01226e3593c29307c43626fc76f594c79629d Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 05:24:51 +0100 Subject: [PATCH 20/59] feat(community_management): add analytics dashboard strip to engagements page - Import AnalyticsDashboardStrip widget - Add AnalyticsDashboardStrip to the top of the engagements page - Configure KPI cards and chart cards relevant to engagements --- .../view/engagements_page.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/community_management/view/engagements_page.dart b/lib/community_management/view/engagements_page.dart index 3ec9b08f..70a9559e 100644 --- a/lib/community_management/view/engagements_page.dart +++ b/lib/community_management/view/engagements_page.dart @@ -7,6 +7,7 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/community_manage import 'package:flutter_news_app_web_dashboard_full_source_code/community_management/widgets/community_action_buttons.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/analytics/analytics_dashboard_strip.dart'; import 'package:intl/intl.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -102,6 +103,19 @@ class _EngagementsPageState extends State { return Column( children: [ + // Analytics Dashboard Strip + const AnalyticsDashboardStrip( + kpiCards: [ + KpiCardId.engagementsTotalReactions, + KpiCardId.engagementsTotalComments, + KpiCardId.engagementsAverageEngagementRate, + ], + chartCards: [ + ChartCardId.engagementsReactionsOverTime, + ChartCardId.engagementsCommentsOverTime, + ChartCardId.engagementsReactionsByType, + ], + ), if (state.engagementsStatus == CommunityManagementStatus.loading && state.engagements.isNotEmpty) From 3799881c3d0cd257c8f17bbf53f1b2dbba02e463 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 05:25:04 +0100 Subject: [PATCH 21/59] feat(community_management): add analytics dashboard strip to reports page - Import AnalyticsDashboardStrip widget - Add AnalyticsDashboardStrip to ReportsPage with relevant KPI and chart cards - Implement layout for analytics strip and existing report list --- lib/community_management/view/reports_page.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/community_management/view/reports_page.dart b/lib/community_management/view/reports_page.dart index 05b47dee..5833832e 100644 --- a/lib/community_management/view/reports_page.dart +++ b/lib/community_management/view/reports_page.dart @@ -8,6 +8,7 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/community_manage import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/extensions/extensions.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/analytics/analytics_dashboard_strip.dart'; import 'package:intl/intl.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -98,6 +99,19 @@ class _ReportsPageState extends State { return Column( children: [ + // Analytics Dashboard Strip + const AnalyticsDashboardStrip( + kpiCards: [ + KpiCardId.engagementsReportsPending, + KpiCardId.engagementsReportsResolved, + KpiCardId.engagementsReportsAverageResolutionTime, + ], + chartCards: [ + ChartCardId.engagementsReportsSubmittedOverTime, + ChartCardId.engagementsReportsResolutionTimeOverTime, + ChartCardId.engagementsReportsByReason, + ], + ), if (state.reportsStatus == CommunityManagementStatus.loading && state.reports.isNotEmpty) const LinearProgressIndicator(), From 8de276ddd00cd20fbc447788175fe98923855f1c Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 05:25:11 +0100 Subject: [PATCH 22/59] feat(content-management): add analytics dashboard strip to headlines page - Import AnalyticsDashboardStrip widget - Add AnalyticsDashboardStrip to the HeadlinesPage layout - Configure KPI and chart cards relevant to content headlines --- lib/content_management/view/headlines_page.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/content_management/view/headlines_page.dart b/lib/content_management/view/headlines_page.dart index 52d4d550..921e4a46 100644 --- a/lib/content_management/view/headlines_page.dart +++ b/lib/content_management/view/headlines_page.dart @@ -8,6 +8,7 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/content_manageme import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/router/routes.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/analytics/analytics_dashboard_strip.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -114,6 +115,19 @@ class _HeadlinesPageState extends State { return Column( children: [ + // Analytics Dashboard Strip + const AnalyticsDashboardStrip( + kpiCards: [ + KpiCardId.contentHeadlinesTotalPublished, + KpiCardId.contentHeadlinesTotalViews, + KpiCardId.contentHeadlinesTotalLikes, + ], + chartCards: [ + ChartCardId.contentHeadlinesViewsOverTime, + ChartCardId.contentHeadlinesLikesOverTime, + ChartCardId.contentHeadlinesViewsByTopic, + ], + ), if (state.headlinesStatus == ContentManagementStatus.loading && state.headlines.isNotEmpty) const LinearProgressIndicator(), From e95609c5051d1517ec168f8d6cfa4d05e4d1381a Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 05:25:28 +0100 Subject: [PATCH 23/59] feat(content-management): add analytics dashboard strip to sources page - Import AnalyticsDashboardStrip widget - Add AnalyticsDashboardStrip to the SourcesPage layout - Include KPI cards for total sources, new sources, and total followers - Add chart cards for headlines published over time, status distribution, and engagement by type --- lib/content_management/view/sources_page.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/content_management/view/sources_page.dart b/lib/content_management/view/sources_page.dart index de74b0f5..28e6eded 100644 --- a/lib/content_management/view/sources_page.dart +++ b/lib/content_management/view/sources_page.dart @@ -9,6 +9,7 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localiz import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/router/routes.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/extensions/extensions.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/analytics/analytics_dashboard_strip.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -113,6 +114,19 @@ class _SourcesPageState extends State { return Column( children: [ + // Analytics Dashboard Strip + const AnalyticsDashboardStrip( + kpiCards: [ + KpiCardId.contentSourcesTotalSources, + KpiCardId.contentSourcesNewSources, + KpiCardId.contentSourcesTotalFollowers, + ], + chartCards: [ + ChartCardId.contentSourcesHeadlinesPublishedOverTime, + ChartCardId.contentSourcesStatusDistribution, + ChartCardId.contentSourcesEngagementByType, + ], + ), if (state.sourcesStatus == ContentManagementStatus.loading && state.sources.isNotEmpty) const LinearProgressIndicator(), From 65bff9794513818aff77cedd16f6fa68ce7806b1 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 05:25:42 +0100 Subject: [PATCH 24/59] feat(content_management): add analytics dashboard strip to topics page - Import AnalyticsDashboardStrip widget - Add AnalyticsDashboardStrip to the TopicsPage layout - Include KPI cards for total topics, new topics, and total followers - Add chart cards for breaking news distribution, headlines published over time, and engagement by topic --- lib/content_management/view/topics_page.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/content_management/view/topics_page.dart b/lib/content_management/view/topics_page.dart index fc12d199..fe80d059 100644 --- a/lib/content_management/view/topics_page.dart +++ b/lib/content_management/view/topics_page.dart @@ -8,6 +8,7 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/content_manageme import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/router/routes.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/analytics/analytics_dashboard_strip.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -109,6 +110,19 @@ class _TopicPageState extends State { return Column( children: [ + // Analytics Dashboard Strip + const AnalyticsDashboardStrip( + kpiCards: [ + KpiCardId.contentTopicsTotalTopics, + KpiCardId.contentTopicsNewTopics, + KpiCardId.contentTopicsTotalFollowers, + ], + chartCards: [ + ChartCardId.contentHeadlinesBreakingNewsDistribution, + ChartCardId.contentTopicsHeadlinesPublishedOverTime, + ChartCardId.contentTopicsEngagementByTopic, + ], + ), if (state.topicsStatus == ContentManagementStatus.loading && state.topics.isNotEmpty) const LinearProgressIndicator(), From 6d3af8fad6910f02e027fecbb7899fe0e1b1e05a Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 07:00:12 +0100 Subject: [PATCH 25/59] chore(deps): update core dependency and fl_chart version - Update core dependency to latest commit (ff02760) - Downgrade fl_chart dependency from ^1.1.1 to ^1.0.0 --- pubspec.lock | 4 ++-- pubspec.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 92a74b98..840de880 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -89,8 +89,8 @@ packages: dependency: "direct main" description: path: "." - ref: a8a1714fe24ad0944fde8e38e6ed3af831568e75 - resolved-ref: a8a1714fe24ad0944fde8e38e6ed3af831568e75 + ref: ff027602d9b33447b3595855f7a548e308d41945 + resolved-ref: ff027602d9b33447b3595855f7a548e308d41945 url: "https://github.com/flutter-news-app-full-source-code/core.git" source: git version: "1.4.0" diff --git a/pubspec.yaml b/pubspec.yaml index be7a212f..a302ce23 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -50,7 +50,7 @@ dependencies: data_table_2: ^2.6.0 device_preview: ^1.2.0 equatable: ^2.0.7 - fl_chart: ^1.1.1 + fl_chart: ^1.0.0 flex_color_scheme: ^8.3.0 flutter: sdk: flutter @@ -95,7 +95,7 @@ dependency_overrides: core: git: url: https://github.com/flutter-news-app-full-source-code/core.git - ref: a8a1714fe24ad0944fde8e38e6ed3af831568e75 + ref: ff027602d9b33447b3595855f7a548e308d41945 http_client: git: url: https://github.com/flutter-news-app-full-source-code/http-client.git From de543837645e09a5e3eef31f083601f137500463 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 07:00:49 +0100 Subject: [PATCH 26/59] refactor(app): remove unused OverviewBloc - Remove OverviewBloc import and initialization in the App widget - This change simplifies the app structure by eliminating unused blocs --- lib/app/view/app.dart | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index bf01897b..05928616 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -18,7 +18,6 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/content_manageme import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/sources_filter/sources_filter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/topics_filter/topics_filter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/overview/bloc/overview_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/router/router.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/shared.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/bloc/user_filter/user_filter_bloc.dart'; @@ -161,9 +160,6 @@ class App extends StatelessWidget { pendingDeletionsService: context.read(), ), ), - BlocProvider( - create: (context) => OverviewBloc(), - ), // The UserFilterBloc is provided here to be available for both the // UserManagementBloc and the UI components. BlocProvider(create: (_) => UserFilterBloc()), From 652891f3554a7f6a0f8e380958d56e2201089c54 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 07:01:06 +0100 Subject: [PATCH 27/59] perf(analytics): ensure in-flight maps are updated after async operations - Add 'await' keyword before removing items from _kpiInFlight, _chartInFlight, and _rankedListInFlight maps - This change ensures that the maps are only updated after the async operations have completed, improving the accuracy of in-flight request tracking --- lib/shared/services/analytics_service.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/shared/services/analytics_service.dart b/lib/shared/services/analytics_service.dart index fc829cc0..c20a2a10 100644 --- a/lib/shared/services/analytics_service.dart +++ b/lib/shared/services/analytics_service.dart @@ -63,7 +63,7 @@ class AnalyticsService { try { return await future; } finally { - _kpiInFlight.remove(id); + await _kpiInFlight.remove(id); } } @@ -92,7 +92,7 @@ class AnalyticsService { try { return await future; } finally { - _chartInFlight.remove(id); + await _chartInFlight.remove(id); } } @@ -121,7 +121,7 @@ class AnalyticsService { try { return await future; } finally { - _rankedListInFlight.remove(id); + await _rankedListInFlight.remove(id); } } From a8b26f731d5a703c8546686dadd13ba9ae92516d Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 07:01:38 +0100 Subject: [PATCH 28/59] feat(analytics): replace custom chart with fl_chart package - Remove custom chart implementation - Add LineChart and BarChart using fl_chart package - Implement axis titles and grid data - Add support for different time frames and labels - Update ChartCard widget to use new chart implementation --- lib/shared/widgets/analytics/chart_card.dart | 255 +++++++++++++++---- 1 file changed, 205 insertions(+), 50 deletions(-) diff --git a/lib/shared/widgets/analytics/chart_card.dart b/lib/shared/widgets/analytics/chart_card.dart index 2b5ac69c..5edd2a31 100644 --- a/lib/shared/widgets/analytics/chart_card.dart +++ b/lib/shared/widgets/analytics/chart_card.dart @@ -1,14 +1,16 @@ import 'package:core/core.dart'; +import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/analytics/analytics_card_shell.dart'; +import 'package:intl/intl.dart'; +import 'package:ui_kit/ui_kit.dart'; /// {@template chart_card} /// A widget that displays a chart (Bar or Line) with time frame toggles. /// -/// Note: Since no external chart library is available, this uses a custom -/// visual representation using standard Flutter widgets. +/// Uses the `fl_chart` package for rendering. /// {@endtemplate} class ChartCard extends StatefulWidget { /// {@macro chart_card} @@ -46,59 +48,212 @@ class _ChartCardState extends State { onChanged: (value) => setState(() => _selectedTimeFrame = value), l10n: l10n, ), - child: LayoutBuilder( - builder: (context, constraints) { - // Find max value to normalize heights - final maxValue = currentPoints - .map((e) => e.value) - .reduce((a, b) => a > b ? a : b) - .toDouble(); - - return Row( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: currentPoints.map((point) { - final heightFactor = - maxValue > 0 ? (point.value / maxValue) : 0.0; - // Ensure a minimum height so the bar is visible - final barHeight = (constraints.maxHeight - 20) * heightFactor; - - return Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - // Tooltip behavior could be added here - Tooltip( - message: '${point.label}: ${point.value}', - child: Container( - height: barHeight < 4 ? 4 : barHeight, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - borderRadius: BorderRadius.circular(4), - ), - ), - ), - const SizedBox(height: 8), - Text( - point.label ?? '', - style: Theme.of(context).textTheme.labelSmall, - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - ], - ), - ), - ); - }).toList(), - ); - }, + child: Padding( + padding: const EdgeInsets.only( + right: AppSpacing.md, + top: AppSpacing.md, + bottom: AppSpacing.xs, + ), + child: widget.data.type == ChartType.line + ? _LineChart(points: currentPoints, timeFrame: _selectedTimeFrame) + : _BarChart(points: currentPoints, timeFrame: _selectedTimeFrame), + ), + ); + } +} + +class _LineChart extends StatelessWidget { + const _LineChart({required this.points, required this.timeFrame}); + + final List points; + final ChartTimeFrame timeFrame; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final primaryColor = theme.colorScheme.primary; + + // Map points to FlSpots + final spots = points.asMap().entries.map((entry) { + return FlSpot(entry.key.toDouble(), entry.value.value.toDouble()); + }).toList(); + + final maxY = points.map((e) => e.value).reduce((a, b) => a > b ? a : b); + + return LineChart( + LineChartData( + gridData: const FlGridData(show: false), + titlesData: FlTitlesData( + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) => _BottomTitle( + value: value, + meta: meta, + points: points, + timeFrame: timeFrame, + ), + reservedSize: 24, + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + getTitlesWidget: (value, meta) { + if (value == 0 || value == maxY) return const SizedBox.shrink(); + return Text( + NumberFormat.compact().format(value), + style: theme.textTheme.labelSmall, + ); + }, + ), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + borderData: FlBorderData(show: false), + lineBarsData: [ + LineChartBarData( + spots: spots, + isCurved: true, + color: primaryColor, + barWidth: 3, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData( + show: true, + color: primaryColor.withOpacity(0.1), + ), + ), + ], + ), + ); + } +} + +class _BarChart extends StatelessWidget { + const _BarChart({required this.points, required this.timeFrame}); + + final List points; + final ChartTimeFrame timeFrame; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final primaryColor = theme.colorScheme.primary; + + final barGroups = points.asMap().entries.map((entry) { + return BarChartGroupData( + x: entry.key, + barRods: [ + BarChartRodData( + toY: entry.value.value.toDouble(), + color: primaryColor, + width: 16, + borderRadius: const BorderRadius.vertical(top: Radius.circular(4)), + ), + ], + ); + }).toList(); + + final maxY = points.map((e) => e.value).reduce((a, b) => a > b ? a : b); + + return BarChart( + BarChartData( + gridData: const FlGridData(show: false), + titlesData: FlTitlesData( + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) => _BottomTitle( + value: value, + meta: meta, + points: points, + timeFrame: timeFrame, + ), + reservedSize: 24, + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + getTitlesWidget: (value, meta) { + if (value == 0 || value == maxY) return const SizedBox.shrink(); + return Text( + NumberFormat.compact().format(value), + style: theme.textTheme.labelSmall, + ); + }, + ), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + borderData: FlBorderData(show: false), + barGroups: barGroups, ), ); } } +class _BottomTitle extends StatelessWidget { + const _BottomTitle({ + required this.value, + required this.points, + required this.timeFrame, + required this.meta, + }); + + final double value; + final List points; + final ChartTimeFrame timeFrame; + final TitleMeta meta; + + @override + Widget build(BuildContext context) { + final index = value.toInt(); + if (index < 0 || index >= points.length) return const SizedBox.shrink(); + + final point = points[index]; + final theme = Theme.of(context); + String text; + + if (point.label != null) { + text = point.label!; + } else if (point.timestamp != null) { + switch (timeFrame) { + case ChartTimeFrame.week: + text = DateFormat.E().format(point.timestamp!); // Mon, Tue + case ChartTimeFrame.month: + text = DateFormat.d().format(point.timestamp!); // 1, 2, 3 + case ChartTimeFrame.year: + text = DateFormat.MMM().format(point.timestamp!); // Jan, Feb + } + } else { + text = ''; + } + + // Simple logic to skip labels if too crowded (e.g., show every 2nd or 5th) + if (points.length > 10 && index % 2 != 0) return const SizedBox.shrink(); + if (points.length > 20 && index % 5 != 0) return const SizedBox.shrink(); + + return SideTitleWidget( + meta: meta, + child: Text(text, style: theme.textTheme.labelSmall), + ); + } +} + class _TimeFrameToggle extends StatelessWidget { const _TimeFrameToggle({ required this.selected, From 06e164ed355d61b97b8ab143954085069e0239e8 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 07:37:31 +0100 Subject: [PATCH 29/59] feat(analytics): enhance AnalyticsCardShell with slot and time frame controls - Implement "Balanced Vertical Edges" design pattern - Add vertical slot navigation for switching cards - Integrate vertical time frame selector for content filtering - Ensure controls are always accessible, even in "No Data" states - Refactor card layout to maximize vertical space usage --- .../analytics/analytics_card_shell.dart | 182 +++++++++++++++--- 1 file changed, 159 insertions(+), 23 deletions(-) diff --git a/lib/shared/widgets/analytics/analytics_card_shell.dart b/lib/shared/widgets/analytics/analytics_card_shell.dart index 543edfa3..eb23eb3e 100644 --- a/lib/shared/widgets/analytics/analytics_card_shell.dart +++ b/lib/shared/widgets/analytics/analytics_card_shell.dart @@ -4,64 +4,200 @@ import 'package:ui_kit/ui_kit.dart'; /// {@template analytics_card_shell} /// A consistent container for all analytics cards (KPI, Chart, Ranked List). /// -/// Provides standard styling, padding, and a header with a title and optional -/// action widgets (like time frame toggles). +/// Implements the "Balanced Vertical Edges" design pattern: +/// - **Left Edge:** Vertical slot navigation (dots) to switch between cards. +/// - **Center:** The main content (Header + Body). +/// - **Right Edge:** Vertical time frame selector (textual). +/// +/// This layout ensures controls are always accessible (even in "No Data" states) +/// and maximizes vertical space for the content. /// {@endtemplate} -class AnalyticsCardShell extends StatelessWidget { +class AnalyticsCardShell extends StatelessWidget { /// {@macro analytics_card_shell} const AnalyticsCardShell({ required this.title, required this.child, - this.action, + required this.currentSlot, + required this.totalSlots, + required this.onSlotChanged, + required this.timeFrames, + required this.selectedTimeFrame, + required this.onTimeFrameChanged, + required this.timeFrameToString, super.key, }); /// The title of the card. final String title; - /// An optional action widget to display in the header (e.g., toggles). - final Widget? action; - /// The main content of the card. final Widget child; + /// The index of the currently active card in the slot. + final int currentSlot; + + /// The total number of cards in this slot. + final int totalSlots; + + /// Callback when a slot dot is clicked. + final ValueChanged onSlotChanged; + + /// The list of available time frames (e.g., Day, Week, Month). + final List timeFrames; + + /// The currently selected time frame. + final T selectedTimeFrame; + + /// Callback when a time frame is selected. + final ValueChanged onTimeFrameChanged; + + /// Function to convert the time frame enum to a display string (e.g., "7D"). + final String Function(T) timeFrameToString; + @override Widget build(BuildContext context) { final theme = Theme.of(context); + return Card( elevation: 0, + margin: EdgeInsets.zero, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppSpacing.md), side: BorderSide(color: theme.colorScheme.outlineVariant), ), child: Padding( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.md, + horizontal: AppSpacing.sm, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( + // --- Left Edge: Slot Navigation --- + if (totalSlots > 1) + _VerticalSlotIndicator( + currentSlot: currentSlot, + totalSlots: totalSlots, + onSlotChanged: onSlotChanged, + ), + if (totalSlots > 1) const SizedBox(width: AppSpacing.sm), + + // --- Center: Content --- + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Text( title, - style: theme.textTheme.titleMedium?.copyWith( + style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurfaceVariant, ), + maxLines: 1, overflow: TextOverflow.ellipsis, ), - ), - if (action != null) ...[ - const SizedBox(width: AppSpacing.sm), - action!, + const SizedBox(height: AppSpacing.sm), + // Body + Expanded(child: child), ], - ], + ), + ), + + const SizedBox(width: AppSpacing.sm), + + // --- Right Edge: Time Frame Selector --- + _VerticalTimeFrameSelector( + timeFrames: timeFrames, + selectedTimeFrame: selectedTimeFrame, + onChanged: onTimeFrameChanged, + labelBuilder: timeFrameToString, ), - const SizedBox(height: AppSpacing.md), - Expanded(child: child), ], ), ), ); } } + +class _VerticalSlotIndicator extends StatelessWidget { + const _VerticalSlotIndicator({ + required this.currentSlot, + required this.totalSlots, + required this.onSlotChanged, + }); + + final int currentSlot; + final int totalSlots; + final ValueChanged onSlotChanged; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(totalSlots, (index) { + final isSelected = index == currentSlot; + return GestureDetector( + onTap: () => onSlotChanged(index), + behavior: HitTestBehavior.opaque, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + width: 6, + height: 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected + ? theme.colorScheme.primary + : theme.colorScheme.surfaceContainerHighest, + ), + ), + ); + }), + ); + } +} + +class _VerticalTimeFrameSelector extends StatelessWidget { + const _VerticalTimeFrameSelector({ + required this.timeFrames, + required this.selectedTimeFrame, + required this.onChanged, + required this.labelBuilder, + }); + + final List timeFrames; + final T selectedTimeFrame; + final ValueChanged onChanged; + final String Function(T) labelBuilder; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + mainAxisAlignment: MainAxisAlignment.start, + children: timeFrames.map((frame) { + final isSelected = frame == selectedTimeFrame; + return InkWell( + onTap: () => onChanged(frame), + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 6, + horizontal: 4, + ), + child: Text( + labelBuilder(frame), + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ); + }).toList(), + ); + } +} From 909acb56c330f4ef01a1866771eaca0bfd5dd447 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 07:38:16 +0100 Subject: [PATCH 30/59] refactor(analytics): upgrade RankedListCard to AnalyticsCardShell - Integrate RankedListCard functionality into AnalyticsCardShell - Remove RankedListCard-specific UI code - Add slot management and time frame selection to AnalyticsCardShell - Simplify list item rendering and improve styling --- .../widgets/analytics/ranked_list_card.dart | 154 +++++++----------- 1 file changed, 63 insertions(+), 91 deletions(-) diff --git a/lib/shared/widgets/analytics/ranked_list_card.dart b/lib/shared/widgets/analytics/ranked_list_card.dart index eb0f71cc..032e9ba7 100644 --- a/lib/shared/widgets/analytics/ranked_list_card.dart +++ b/lib/shared/widgets/analytics/ranked_list_card.dart @@ -12,11 +12,16 @@ class RankedListCard extends StatefulWidget { /// {@macro ranked_list_card} const RankedListCard({ required this.data, + required this.slotIndex, + required this.totalSlots, + required this.onSlotChanged, super.key, }); - /// The data object containing ranked lists for all time frames. final RankedListCardData data; + final int slotIndex; + final int totalSlots; + final ValueChanged onSlotChanged; @override State createState() => _RankedListCardState(); @@ -31,100 +36,67 @@ class _RankedListCardState extends State { final theme = Theme.of(context); final currentList = widget.data.timeFrames[_selectedTimeFrame]; - if (currentList == null || currentList.isEmpty) { - return AnalyticsCardShell( - title: widget.data.label, - child: Center(child: Text(l10n.noDataAvailable)), - ); - } - - return AnalyticsCardShell( + return AnalyticsCardShell( title: widget.data.label, - action: _TimeFrameToggle( - selected: _selectedTimeFrame, - onChanged: (value) => setState(() => _selectedTimeFrame = value), - l10n: l10n, - ), - child: ListView.separated( - itemCount: currentList.length, - separatorBuilder: (context, index) => const Divider(height: 1), - itemBuilder: (context, index) { - final item = currentList[index]; - return ListTile( - contentPadding: EdgeInsets.zero, - leading: CircleAvatar( - backgroundColor: theme.colorScheme.primaryContainer, - foregroundColor: theme.colorScheme.onPrimaryContainer, - radius: 12, - child: Text( - '${index + 1}', - style: theme.textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - title: Text( - item.displayTitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium, + currentSlot: widget.slotIndex, + totalSlots: widget.totalSlots, + onSlotChanged: widget.onSlotChanged, + timeFrames: RankedListTimeFrame.values, + selectedTimeFrame: _selectedTimeFrame, + onTimeFrameChanged: (value) => setState(() => _selectedTimeFrame = value), + timeFrameToString: (frame) => _timeFrameToLabel(frame, l10n), + child: (currentList == null || currentList.isEmpty) + ? Center(child: Text(l10n.noDataAvailable)) + : ListView.separated( + padding: EdgeInsets.zero, + itemCount: currentList.length, + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, index) { + final item = currentList[index]; + return ListTile( + contentPadding: EdgeInsets.zero, + dense: true, + leading: CircleAvatar( + backgroundColor: theme.colorScheme.primaryContainer, + foregroundColor: theme.colorScheme.onPrimaryContainer, + radius: 10, + child: Text( + '${index + 1}', + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.bold, + fontSize: 10, + ), + ), + ), + title: Text( + item.displayTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium, + ), + trailing: Text( + item.metricValue.toString(), + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + ); + }, ), - trailing: Text( - item.metricValue.toString(), - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.bold, - color: theme.colorScheme.primary, - ), - ), - ); - }, - ), ); } -} - -class _TimeFrameToggle extends StatelessWidget { - const _TimeFrameToggle({ - required this.selected, - required this.onChanged, - required this.l10n, - }); - - final RankedListTimeFrame selected; - final ValueChanged onChanged; - final AppLocalizations l10n; - @override - Widget build(BuildContext context) { - return SegmentedButton( - segments: [ - ButtonSegment( - value: RankedListTimeFrame.day, - label: Text(l10n.timeFrameDay), - ), - ButtonSegment( - value: RankedListTimeFrame.week, - label: Text(l10n.timeFrameWeek), - ), - ButtonSegment( - value: RankedListTimeFrame.month, - label: Text(l10n.timeFrameMonth), - ), - ButtonSegment( - value: RankedListTimeFrame.year, - label: Text(l10n.timeFrameYear), - ), - ], - selected: {selected}, - onSelectionChanged: (Set newSelection) { - onChanged(newSelection.first); - }, - showSelectedIcon: false, - style: ButtonStyle( - visualDensity: VisualDensity.compact, - padding: WidgetStateProperty.all(EdgeInsets.zero), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ); + String _timeFrameToLabel(RankedListTimeFrame frame, AppLocalizations l10n) { + switch (frame) { + case RankedListTimeFrame.day: + return l10n.timeFrameDay; + case RankedListTimeFrame.week: + return l10n.timeFrameWeek; + case RankedListTimeFrame.month: + return l10n.timeFrameMonth; + case RankedListTimeFrame.year: + return l10n.timeFrameYear; + } } } From 139a037cdd046b73cd2dea7832732bcc9a5428ab Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 07:38:37 +0100 Subject: [PATCH 31/59] refactor(analytics): improve chart card usability and appearance - Add slot functionality for better data segmentation - Implement consistent time frame toggling across chart cards - Enhance label handling for different time frames - Adjust chart margins and spacing for better readability - Improve bar and line thickness for better visual impact - Add min and max Y values for bar and line charts --- lib/shared/widgets/analytics/chart_card.dart | 168 +++++++------------ 1 file changed, 64 insertions(+), 104 deletions(-) diff --git a/lib/shared/widgets/analytics/chart_card.dart b/lib/shared/widgets/analytics/chart_card.dart index 5edd2a31..b16b36b9 100644 --- a/lib/shared/widgets/analytics/chart_card.dart +++ b/lib/shared/widgets/analytics/chart_card.dart @@ -9,18 +9,21 @@ import 'package:ui_kit/ui_kit.dart'; /// {@template chart_card} /// A widget that displays a chart (Bar or Line) with time frame toggles. -/// -/// Uses the `fl_chart` package for rendering. /// {@endtemplate} class ChartCard extends StatefulWidget { /// {@macro chart_card} const ChartCard({ required this.data, + required this.slotIndex, + required this.totalSlots, + required this.onSlotChanged, super.key, }); - /// The data object containing chart points for all time frames. final ChartCardData data; + final int slotIndex; + final int totalSlots; + final ValueChanged onSlotChanged; @override State createState() => _ChartCardState(); @@ -34,32 +37,45 @@ class _ChartCardState extends State { final l10n = AppLocalizationsX(context).l10n; final currentPoints = widget.data.timeFrames[_selectedTimeFrame]; - if (currentPoints == null || currentPoints.isEmpty) { - return AnalyticsCardShell( - title: widget.data.label, - child: Center(child: Text(l10n.noDataAvailable)), - ); - } - - return AnalyticsCardShell( + return AnalyticsCardShell( title: widget.data.label, - action: _TimeFrameToggle( - selected: _selectedTimeFrame, - onChanged: (value) => setState(() => _selectedTimeFrame = value), - l10n: l10n, - ), - child: Padding( - padding: const EdgeInsets.only( - right: AppSpacing.md, - top: AppSpacing.md, - bottom: AppSpacing.xs, - ), - child: widget.data.type == ChartType.line - ? _LineChart(points: currentPoints, timeFrame: _selectedTimeFrame) - : _BarChart(points: currentPoints, timeFrame: _selectedTimeFrame), - ), + currentSlot: widget.slotIndex, + totalSlots: widget.totalSlots, + onSlotChanged: widget.onSlotChanged, + timeFrames: ChartTimeFrame.values, + selectedTimeFrame: _selectedTimeFrame, + onTimeFrameChanged: (value) => setState(() => _selectedTimeFrame = value), + timeFrameToString: (frame) => _timeFrameToLabel(frame, l10n), + child: (currentPoints == null || currentPoints.isEmpty) + ? Center(child: Text(l10n.noDataAvailable)) + : Padding( + padding: const EdgeInsets.only( + top: AppSpacing.sm, + bottom: AppSpacing.xs, + ), + child: widget.data.type == ChartType.line + ? _LineChart( + points: currentPoints, + timeFrame: _selectedTimeFrame, + ) + : _BarChart( + points: currentPoints, + timeFrame: _selectedTimeFrame, + ), + ), ); } + + String _timeFrameToLabel(ChartTimeFrame frame, AppLocalizations l10n) { + switch (frame) { + case ChartTimeFrame.week: + return l10n.timeFrameWeek; + case ChartTimeFrame.month: + return l10n.timeFrameMonth; + case ChartTimeFrame.year: + return l10n.timeFrameYear; + } + } } class _LineChart extends StatelessWidget { @@ -73,7 +89,6 @@ class _LineChart extends StatelessWidget { final theme = Theme.of(context); final primaryColor = theme.colorScheme.primary; - // Map points to FlSpots final spots = points.asMap().entries.map((entry) { return FlSpot(entry.key.toDouble(), entry.value.value.toDouble()); }).toList(); @@ -93,21 +108,11 @@ class _LineChart extends StatelessWidget { points: points, timeFrame: timeFrame, ), - reservedSize: 24, + reservedSize: 20, ), ), - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 40, - getTitlesWidget: (value, meta) { - if (value == 0 || value == maxY) return const SizedBox.shrink(); - return Text( - NumberFormat.compact().format(value), - style: theme.textTheme.labelSmall, - ); - }, - ), + leftTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), ), topTitles: const AxisTitles( sideTitles: SideTitles(showTitles: false), @@ -122,7 +127,7 @@ class _LineChart extends StatelessWidget { spots: spots, isCurved: true, color: primaryColor, - barWidth: 3, + barWidth: 2, isStrokeCapRound: true, dotData: const FlDotData(show: false), belowBarData: BarAreaData( @@ -131,6 +136,8 @@ class _LineChart extends StatelessWidget { ), ), ], + minY: 0, + maxY: maxY.toDouble() * 1.1, // Add some headroom ), ); } @@ -154,7 +161,7 @@ class _BarChart extends StatelessWidget { BarChartRodData( toY: entry.value.value.toDouble(), color: primaryColor, - width: 16, + width: 12, borderRadius: const BorderRadius.vertical(top: Radius.circular(4)), ), ], @@ -176,21 +183,11 @@ class _BarChart extends StatelessWidget { points: points, timeFrame: timeFrame, ), - reservedSize: 24, + reservedSize: 20, ), ), - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 40, - getTitlesWidget: (value, meta) { - if (value == 0 || value == maxY) return const SizedBox.shrink(); - return Text( - NumberFormat.compact().format(value), - style: theme.textTheme.labelSmall, - ); - }, - ), + leftTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), ), topTitles: const AxisTitles( sideTitles: SideTitles(showTitles: false), @@ -201,6 +198,8 @@ class _BarChart extends StatelessWidget { ), borderData: FlBorderData(show: false), barGroups: barGroups, + minY: 0, + maxY: maxY.toDouble() * 1.1, ), ); } @@ -230,67 +229,28 @@ class _BottomTitle extends StatelessWidget { if (point.label != null) { text = point.label!; + // Truncate long labels + if (text.length > 3) text = text.substring(0, 3); } else if (point.timestamp != null) { switch (timeFrame) { case ChartTimeFrame.week: - text = DateFormat.E().format(point.timestamp!); // Mon, Tue + text = DateFormat.E().format(point.timestamp!).substring(0, 1); // M case ChartTimeFrame.month: - text = DateFormat.d().format(point.timestamp!); // 1, 2, 3 + // Show every 5th day to avoid crowding + if (index % 5 != 0) return const SizedBox.shrink(); + text = DateFormat.d().format(point.timestamp!); case ChartTimeFrame.year: - text = DateFormat.MMM().format(point.timestamp!); // Jan, Feb + text = DateFormat.MMM().format(point.timestamp!).substring(0, 1); // J } } else { text = ''; } - // Simple logic to skip labels if too crowded (e.g., show every 2nd or 5th) - if (points.length > 10 && index % 2 != 0) return const SizedBox.shrink(); - if (points.length > 20 && index % 5 != 0) return const SizedBox.shrink(); - return SideTitleWidget( meta: meta, - child: Text(text, style: theme.textTheme.labelSmall), - ); - } -} - -class _TimeFrameToggle extends StatelessWidget { - const _TimeFrameToggle({ - required this.selected, - required this.onChanged, - required this.l10n, - }); - - final ChartTimeFrame selected; - final ValueChanged onChanged; - final AppLocalizations l10n; - - @override - Widget build(BuildContext context) { - return SegmentedButton( - segments: [ - ButtonSegment( - value: ChartTimeFrame.week, - label: Text(l10n.timeFrameWeek), - ), - ButtonSegment( - value: ChartTimeFrame.month, - label: Text(l10n.timeFrameMonth), - ), - ButtonSegment( - value: ChartTimeFrame.year, - label: Text(l10n.timeFrameYear), - ), - ], - selected: {selected}, - onSelectionChanged: (Set newSelection) { - onChanged(newSelection.first); - }, - showSelectedIcon: false, - style: ButtonStyle( - visualDensity: VisualDensity.compact, - padding: WidgetStateProperty.all(EdgeInsets.zero), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, + child: Text( + text, + style: theme.textTheme.labelSmall?.copyWith(fontSize: 10), ), ); } From d82d2d821fc1c5d2554858fde3864fd132d6b6d1 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 07:38:48 +0100 Subject: [PATCH 32/59] refactor(analytics): revamp KPI card layout and interaction - Integrate AnalyticsCardShell generics for improved type safety - Implement slot navigation within the card - Simplify trend indication with a dedicated widget - Localize time frame labels - Remove inline number formatting for future enhancement --- lib/shared/widgets/analytics/kpi_card.dart | 169 ++++++++++----------- 1 file changed, 82 insertions(+), 87 deletions(-) diff --git a/lib/shared/widgets/analytics/kpi_card.dart b/lib/shared/widgets/analytics/kpi_card.dart index d419a24e..11eb8a58 100644 --- a/lib/shared/widgets/analytics/kpi_card.dart +++ b/lib/shared/widgets/analytics/kpi_card.dart @@ -13,18 +13,29 @@ class KpiCard extends StatefulWidget { /// {@macro kpi_card} const KpiCard({ required this.data, + required this.slotIndex, + required this.totalSlots, + required this.onSlotChanged, super.key, }); /// The data object containing values for all time frames. final KpiCardData data; + /// The index of this card in the parent slot. + final int slotIndex; + + /// The total number of cards in the parent slot. + final int totalSlots; + + /// Callback to change the active card in the slot. + final ValueChanged onSlotChanged; + @override State createState() => _KpiCardState(); } class _KpiCardState extends State { - // Default to 'week' as a reasonable starting point. KpiTimeFrame _selectedTimeFrame = KpiTimeFrame.week; @override @@ -33,108 +44,92 @@ class _KpiCardState extends State { final l10n = AppLocalizationsX(context).l10n; final currentData = widget.data.timeFrames[_selectedTimeFrame]; - // Fallback if data for the selected time frame is missing. - if (currentData == null) { - return AnalyticsCardShell( - title: widget.data.label, - child: Center(child: Text(l10n.noDataAvailable)), - ); - } - - final isPositiveTrend = !currentData.trend.startsWith('-'); - final trendColor = - isPositiveTrend ? theme.colorScheme.primary : theme.colorScheme.error; - final trendIcon = - isPositiveTrend ? Icons.arrow_upward : Icons.arrow_downward; - - return AnalyticsCardShell( + return AnalyticsCardShell( title: widget.data.label, - action: _TimeFrameToggle( - selected: _selectedTimeFrame, - onChanged: (value) => setState(() => _selectedTimeFrame = value), - l10n: l10n, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - // Format numbers nicely (e.g., 1,234). - // For simplicity, using toString here, but NumberFormat is better. - currentData.value.toString(), - style: theme.textTheme.displayMedium?.copyWith( - fontWeight: FontWeight.bold, - color: theme.colorScheme.primary, - ), - ), - const SizedBox(height: AppSpacing.sm), - Row( - children: [ - Icon(trendIcon, size: 16, color: trendColor), - const SizedBox(width: 4), - Text( - currentData.trend, - style: theme.textTheme.bodyMedium?.copyWith( - color: trendColor, - fontWeight: FontWeight.bold, + currentSlot: widget.slotIndex, + totalSlots: widget.totalSlots, + onSlotChanged: widget.onSlotChanged, + timeFrames: KpiTimeFrame.values, + selectedTimeFrame: _selectedTimeFrame, + onTimeFrameChanged: (value) => setState(() => _selectedTimeFrame = value), + timeFrameToString: (frame) => _timeFrameToLabel(frame, l10n), + child: currentData == null + ? Center(child: Text(l10n.noDataAvailable)) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + currentData.value.toString(), + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), ), - ), - const SizedBox(width: AppSpacing.xs), - Text( - l10n.vsPreviousPeriod, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + const SizedBox(height: AppSpacing.xs), + _TrendIndicator( + trend: currentData.trend, + l10n: l10n, ), - ), - ], - ), - ], - ), + ], + ), ); } + + String _timeFrameToLabel(KpiTimeFrame frame, AppLocalizations l10n) { + switch (frame) { + case KpiTimeFrame.day: + return l10n.timeFrameDay; + case KpiTimeFrame.week: + return l10n.timeFrameWeek; + case KpiTimeFrame.month: + return l10n.timeFrameMonth; + case KpiTimeFrame.year: + return l10n.timeFrameYear; + } + } } -class _TimeFrameToggle extends StatelessWidget { - const _TimeFrameToggle({ - required this.selected, - required this.onChanged, +class _TrendIndicator extends StatelessWidget { + const _TrendIndicator({ + required this.trend, required this.l10n, }); - final KpiTimeFrame selected; - final ValueChanged onChanged; + final String trend; final AppLocalizations l10n; @override Widget build(BuildContext context) { - return SegmentedButton( - segments: [ - ButtonSegment( - value: KpiTimeFrame.day, - label: Text(l10n.timeFrameDay), - ), - ButtonSegment( - value: KpiTimeFrame.week, - label: Text(l10n.timeFrameWeek), - ), - ButtonSegment( - value: KpiTimeFrame.month, - label: Text(l10n.timeFrameMonth), + final theme = Theme.of(context); + final isPositive = !trend.startsWith('-'); + final color = isPositive + ? theme.colorScheme.primary + : theme.colorScheme.error; + final icon = isPositive ? Icons.arrow_upward : Icons.arrow_downward; + + return Tooltip( + message: l10n.vsPreviousPeriod, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), ), - ButtonSegment( - value: KpiTimeFrame.year, - label: Text(l10n.timeFrameYear), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 12, color: color), + const SizedBox(width: 2), + Text( + trend, + style: theme.textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.bold, + ), + ), + ], ), - ], - selected: {selected}, - onSelectionChanged: (Set newSelection) { - onChanged(newSelection.first); - }, - showSelectedIcon: false, - style: ButtonStyle( - visualDensity: VisualDensity.compact, - padding: WidgetStateProperty.all(EdgeInsets.zero), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), ); } From 14747b7d6bea3932beeb72271d0a3ac4cca52836 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 07:39:44 +0100 Subject: [PATCH 33/59] refactor(analytics): improve AnalyticsCardSlot widget - Remove unused import and comments --- .../analytics/analytics_card_slot.dart | 92 ++++++------------- 1 file changed, 29 insertions(+), 63 deletions(-) diff --git a/lib/shared/widgets/analytics/analytics_card_slot.dart b/lib/shared/widgets/analytics/analytics_card_slot.dart index 1c3a0bfa..734f7f20 100644 --- a/lib/shared/widgets/analytics/analytics_card_slot.dart +++ b/lib/shared/widgets/analytics/analytics_card_slot.dart @@ -5,15 +5,12 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/shared/services/ import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/analytics/chart_card.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/analytics/kpi_card.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/analytics/ranked_list_card.dart'; -import 'package:ui_kit/ui_kit.dart'; /// {@template analytics_card_slot} -/// A widget that manages a slot containing multiple analytics cards, allowing -/// users to switch between them. +/// A widget that manages a slot containing multiple analytics cards. /// -/// This widget is generic and can handle [KpiCardId], [ChartCardId], or -/// [RankedListCardId]. It fetches data using the [AnalyticsService] and -/// displays the appropriate card widget. +/// It fetches data using the [AnalyticsService] and displays the appropriate +/// card widget, passing down slot navigation state to the child card. /// {@endtemplate} class AnalyticsCardSlot extends StatefulWidget { /// {@macro analytics_card_slot} @@ -23,7 +20,6 @@ class AnalyticsCardSlot extends StatefulWidget { }) : assert(cardIds.length > 0, 'Must provide at least one card ID'); /// The list of card IDs available in this slot. - /// Must be a list of [KpiCardId], [ChartCardId], or [RankedListCardId]. final List cardIds; @override @@ -34,61 +30,33 @@ class _AnalyticsCardSlotState extends State> { int _currentIndex = 0; + void _onSlotChanged(int index) { + setState(() { + _currentIndex = index; + }); + } + @override Widget build(BuildContext context) { final currentId = widget.cardIds[_currentIndex]; - final theme = Theme.of(context); - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Card Content - Expanded( - child: _buildCardContent(currentId), - ), - // Navigation Dots (only if multiple cards) - if (widget.cardIds.length > 1) ...[ - const SizedBox(height: AppSpacing.sm), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: List.generate(widget.cardIds.length, (index) { - final isSelected = index == _currentIndex; - return GestureDetector( - onTap: () => setState(() => _currentIndex = index), - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 4), - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isSelected - ? theme.colorScheme.primary - : theme.colorScheme.surfaceContainerHighest, - ), - ), - ); - }), - ), - ], - ], - ); + return _buildCardContent(currentId); } Widget _buildCardContent(T id) { final analyticsService = context.read(); + final totalSlots = widget.cardIds.length; if (id is KpiCardId) { return FutureBuilder( future: analyticsService.getKpi(id), builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - if (snapshot.hasError) { - return Center(child: Text('Error: ${snapshot.error}')); - } if (snapshot.hasData) { - return KpiCard(data: snapshot.data!); + return KpiCard( + data: snapshot.data!, + slotIndex: _currentIndex, + totalSlots: totalSlots, + onSlotChanged: _onSlotChanged, + ); } return const SizedBox.shrink(); }, @@ -97,14 +65,13 @@ class _AnalyticsCardSlotState return FutureBuilder( future: analyticsService.getChart(id), builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - if (snapshot.hasError) { - return Center(child: Text('Error: ${snapshot.error}')); - } if (snapshot.hasData) { - return ChartCard(data: snapshot.data!); + return ChartCard( + data: snapshot.data!, + slotIndex: _currentIndex, + totalSlots: totalSlots, + onSlotChanged: _onSlotChanged, + ); } return const SizedBox.shrink(); }, @@ -113,14 +80,13 @@ class _AnalyticsCardSlotState return FutureBuilder( future: analyticsService.getRankedList(id), builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - if (snapshot.hasError) { - return Center(child: Text('Error: ${snapshot.error}')); - } if (snapshot.hasData) { - return RankedListCard(data: snapshot.data!); + return RankedListCard( + data: snapshot.data!, + slotIndex: _currentIndex, + totalSlots: totalSlots, + onSlotChanged: _onSlotChanged, + ); } return const SizedBox.shrink(); }, From 1f43244cbb6ac4d85f7c21653020c5c896383df1 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 07:49:04 +0100 Subject: [PATCH 34/59] fix(community_management): update layout of analytics dashboard strip - Wrap AnalyticsDashboardStrip in a SizedBox with fixed height of 160 - This change ensures consistent layout regardless of the number of reaction types --- .../view/engagements_page.dart | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/lib/community_management/view/engagements_page.dart b/lib/community_management/view/engagements_page.dart index 70a9559e..02bfeeaf 100644 --- a/lib/community_management/view/engagements_page.dart +++ b/lib/community_management/view/engagements_page.dart @@ -104,17 +104,20 @@ class _EngagementsPageState extends State { return Column( children: [ // Analytics Dashboard Strip - const AnalyticsDashboardStrip( - kpiCards: [ - KpiCardId.engagementsTotalReactions, - KpiCardId.engagementsTotalComments, - KpiCardId.engagementsAverageEngagementRate, - ], - chartCards: [ - ChartCardId.engagementsReactionsOverTime, - ChartCardId.engagementsCommentsOverTime, - ChartCardId.engagementsReactionsByType, - ], + const SizedBox( + height: 160, + child: AnalyticsDashboardStrip( + kpiCards: [ + KpiCardId.engagementsTotalReactions, + KpiCardId.engagementsTotalComments, + KpiCardId.engagementsAverageEngagementRate, + ], + chartCards: [ + ChartCardId.engagementsReactionsOverTime, + ChartCardId.engagementsCommentsOverTime, + ChartCardId.engagementsReactionsByType, + ], + ), ), if (state.engagementsStatus == CommunityManagementStatus.loading && From 8406fad445b7f468923f00155400c3fae3ccc2b2 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 07:49:11 +0100 Subject: [PATCH 35/59] fix(community_management): update layout of analytics dashboard strip - Wrap AnalyticsDashboardStrip in a SizedBox with fixed height of 160 - This change ensures consistent layout regardless of the number of reaction types --- .../view/app_reviews_page.dart | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/lib/community_management/view/app_reviews_page.dart b/lib/community_management/view/app_reviews_page.dart index a3948d91..dff32d74 100644 --- a/lib/community_management/view/app_reviews_page.dart +++ b/lib/community_management/view/app_reviews_page.dart @@ -105,17 +105,20 @@ class _AppReviewsPageState extends State { return Column( children: [ // Analytics Dashboard Strip - const AnalyticsDashboardStrip( - kpiCards: [ - KpiCardId.engagementsAppReviewsTotalFeedback, - KpiCardId.engagementsAppReviewsPositiveFeedback, - KpiCardId.engagementsAppReviewsStoreRequests, - ], - chartCards: [ - ChartCardId.engagementsAppReviewsFeedbackOverTime, - ChartCardId.engagementsAppReviewsPositiveVsNegative, - ChartCardId.engagementsAppReviewsStoreRequestsOverTime, - ], + const SizedBox( + height: 160, + child: AnalyticsDashboardStrip( + kpiCards: [ + KpiCardId.engagementsAppReviewsTotalFeedback, + KpiCardId.engagementsAppReviewsPositiveFeedback, + KpiCardId.engagementsAppReviewsStoreRequests, + ], + chartCards: [ + ChartCardId.engagementsAppReviewsFeedbackOverTime, + ChartCardId.engagementsAppReviewsPositiveVsNegative, + ChartCardId.engagementsAppReviewsStoreRequestsOverTime, + ], + ), ), if (state.appReviewsStatus == CommunityManagementStatus.loading && state.appReviews.isNotEmpty) From 7eb30c93a2384459f2c44c31ee2f02716751ca60 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 07:49:15 +0100 Subject: [PATCH 36/59] fix(community_management): update layout of analytics dashboard strip - Wrap AnalyticsDashboardStrip in a SizedBox with fixed height of 160 - This change ensures consistent layout regardless of the number of reaction types --- .../view/reports_page.dart | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/lib/community_management/view/reports_page.dart b/lib/community_management/view/reports_page.dart index 5833832e..ca2572d3 100644 --- a/lib/community_management/view/reports_page.dart +++ b/lib/community_management/view/reports_page.dart @@ -100,17 +100,20 @@ class _ReportsPageState extends State { return Column( children: [ // Analytics Dashboard Strip - const AnalyticsDashboardStrip( - kpiCards: [ - KpiCardId.engagementsReportsPending, - KpiCardId.engagementsReportsResolved, - KpiCardId.engagementsReportsAverageResolutionTime, - ], - chartCards: [ - ChartCardId.engagementsReportsSubmittedOverTime, - ChartCardId.engagementsReportsResolutionTimeOverTime, - ChartCardId.engagementsReportsByReason, - ], + const SizedBox( + height: 160, + child: AnalyticsDashboardStrip( + kpiCards: [ + KpiCardId.engagementsReportsPending, + KpiCardId.engagementsReportsResolved, + KpiCardId.engagementsReportsAverageResolutionTime, + ], + chartCards: [ + ChartCardId.engagementsReportsSubmittedOverTime, + ChartCardId.engagementsReportsResolutionTimeOverTime, + ChartCardId.engagementsReportsByReason, + ], + ), ), if (state.reportsStatus == CommunityManagementStatus.loading && state.reports.isNotEmpty) From 8e91dd6152c832497a7c284b34d4687aad3a3635 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 07:49:26 +0100 Subject: [PATCH 37/59] fix(content_management): update layout of analytics dashboard strip - Wrap AnalyticsDashboardStrip in a SizedBox with fixed height of 160 - This change ensures consistent layout regardless of the number of reaction types --- .../view/headlines_page.dart | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/lib/content_management/view/headlines_page.dart b/lib/content_management/view/headlines_page.dart index 921e4a46..5d69e9d1 100644 --- a/lib/content_management/view/headlines_page.dart +++ b/lib/content_management/view/headlines_page.dart @@ -116,17 +116,20 @@ class _HeadlinesPageState extends State { return Column( children: [ // Analytics Dashboard Strip - const AnalyticsDashboardStrip( - kpiCards: [ - KpiCardId.contentHeadlinesTotalPublished, - KpiCardId.contentHeadlinesTotalViews, - KpiCardId.contentHeadlinesTotalLikes, - ], - chartCards: [ - ChartCardId.contentHeadlinesViewsOverTime, - ChartCardId.contentHeadlinesLikesOverTime, - ChartCardId.contentHeadlinesViewsByTopic, - ], + const SizedBox( + height: 160, + child: AnalyticsDashboardStrip( + kpiCards: [ + KpiCardId.contentHeadlinesTotalPublished, + KpiCardId.contentHeadlinesTotalViews, + KpiCardId.contentHeadlinesTotalLikes, + ], + chartCards: [ + ChartCardId.contentHeadlinesViewsOverTime, + ChartCardId.contentHeadlinesLikesOverTime, + ChartCardId.contentHeadlinesViewsByTopic, + ], + ), ), if (state.headlinesStatus == ContentManagementStatus.loading && state.headlines.isNotEmpty) From b0e72f376e159f72f5938973170940ee1981ebfb Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 07:49:29 +0100 Subject: [PATCH 38/59] fix(content_management): update layout of analytics dashboard strip - Wrap AnalyticsDashboardStrip in a SizedBox with fixed height of 160 - This change ensures consistent layout regardless of the number of reaction types --- lib/content_management/view/sources_page.dart | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/lib/content_management/view/sources_page.dart b/lib/content_management/view/sources_page.dart index 28e6eded..4dc71e0d 100644 --- a/lib/content_management/view/sources_page.dart +++ b/lib/content_management/view/sources_page.dart @@ -115,17 +115,20 @@ class _SourcesPageState extends State { return Column( children: [ // Analytics Dashboard Strip - const AnalyticsDashboardStrip( - kpiCards: [ - KpiCardId.contentSourcesTotalSources, - KpiCardId.contentSourcesNewSources, - KpiCardId.contentSourcesTotalFollowers, - ], - chartCards: [ - ChartCardId.contentSourcesHeadlinesPublishedOverTime, - ChartCardId.contentSourcesStatusDistribution, - ChartCardId.contentSourcesEngagementByType, - ], + const SizedBox( + height: 160, + child: AnalyticsDashboardStrip( + kpiCards: [ + KpiCardId.contentSourcesTotalSources, + KpiCardId.contentSourcesNewSources, + KpiCardId.contentSourcesTotalFollowers, + ], + chartCards: [ + ChartCardId.contentSourcesHeadlinesPublishedOverTime, + ChartCardId.contentSourcesStatusDistribution, + ChartCardId.contentSourcesEngagementByType, + ], + ), ), if (state.sourcesStatus == ContentManagementStatus.loading && state.sources.isNotEmpty) From 9f1e0961c7b62a386f34dfdc4d23cefb34ca4b02 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 07:49:33 +0100 Subject: [PATCH 39/59] fix(content_management): update layout of analytics dashboard strip - Wrap AnalyticsDashboardStrip in a SizedBox with fixed height of 160 - This change ensures consistent layout regardless of the number of reaction types --- lib/content_management/view/topics_page.dart | 25 +++++++++++--------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/lib/content_management/view/topics_page.dart b/lib/content_management/view/topics_page.dart index fe80d059..cd91208f 100644 --- a/lib/content_management/view/topics_page.dart +++ b/lib/content_management/view/topics_page.dart @@ -111,17 +111,20 @@ class _TopicPageState extends State { return Column( children: [ // Analytics Dashboard Strip - const AnalyticsDashboardStrip( - kpiCards: [ - KpiCardId.contentTopicsTotalTopics, - KpiCardId.contentTopicsNewTopics, - KpiCardId.contentTopicsTotalFollowers, - ], - chartCards: [ - ChartCardId.contentHeadlinesBreakingNewsDistribution, - ChartCardId.contentTopicsHeadlinesPublishedOverTime, - ChartCardId.contentTopicsEngagementByTopic, - ], + const SizedBox( + height: 160, + child: AnalyticsDashboardStrip( + kpiCards: [ + KpiCardId.contentTopicsTotalTopics, + KpiCardId.contentTopicsNewTopics, + KpiCardId.contentTopicsTotalFollowers, + ], + chartCards: [ + ChartCardId.contentHeadlinesBreakingNewsDistribution, + ChartCardId.contentTopicsHeadlinesPublishedOverTime, + ChartCardId.contentTopicsEngagementByTopic, + ], + ), ), if (state.topicsStatus == ContentManagementStatus.loading && state.topics.isNotEmpty) From 056184e7f0fb9bbf230fb58c201a1c54dffc146e Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 07:49:44 +0100 Subject: [PATCH 40/59] fix(users_management): update layout of analytics dashboard strip - Wrap AnalyticsDashboardStrip in a SizedBox with fixed height of 160 - This change ensures consistent layout regardless of the number of reaction types --- lib/user_management/view/users_page.dart | 25 +++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/lib/user_management/view/users_page.dart b/lib/user_management/view/users_page.dart index e5824bfd..3342b7ec 100644 --- a/lib/user_management/view/users_page.dart +++ b/lib/user_management/view/users_page.dart @@ -123,17 +123,20 @@ class _UsersPageState extends State { return Column( children: [ // Analytics Dashboard Strip - const AnalyticsDashboardStrip( - kpiCards: [ - KpiCardId.usersTotalRegistered, - KpiCardId.usersNewRegistrations, - KpiCardId.usersActiveUsers, - ], - chartCards: [ - ChartCardId.usersRegistrationsOverTime, - ChartCardId.usersActiveUsersOverTime, - ChartCardId.usersRoleDistribution, - ], + const SizedBox( + height: 160, + child: AnalyticsDashboardStrip( + kpiCards: [ + KpiCardId.usersTotalRegistered, + KpiCardId.usersNewRegistrations, + KpiCardId.usersActiveUsers, + ], + chartCards: [ + ChartCardId.usersRegistrationsOverTime, + ChartCardId.usersActiveUsersOverTime, + ChartCardId.usersRoleDistribution, + ], + ), ), // Show a linear progress indicator during subsequent loads/pagination. if (state.status == UserManagementStatus.loading && From ca3b782b71512d181d5140456b7e7c516610d7d0 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 07:49:58 +0100 Subject: [PATCH 41/59] refactor(overview): implement responsive layout for dashboard - Replace fixed grid layout with responsive design using LayoutBuilder - Adapt card heights and layouts for different screen sizes - Implement desktop, tablet, and mobile layouts for better user experience --- lib/overview/view/overview_page.dart | 242 ++++++++++++++++++++++----- 1 file changed, 199 insertions(+), 43 deletions(-) diff --git a/lib/overview/view/overview_page.dart b/lib/overview/view/overview_page.dart index 345b1ea8..15e2c031 100644 --- a/lib/overview/view/overview_page.dart +++ b/lib/overview/view/overview_page.dart @@ -7,8 +7,8 @@ import 'package:ui_kit/ui_kit.dart'; /// {@template overview_page} /// The main dashboard overview page, displaying key statistics and quick actions. /// -/// This page uses a fixed grid layout to display high-level KPIs, trends, and -/// ranked lists, providing a "Mission Control" view of the application. +/// This page uses a responsive grid layout to display high-level KPIs, trends, +/// and ranked lists, adapting to different screen sizes. /// {@endtemplate} class OverviewPage extends StatelessWidget { /// {@macro overview_page} @@ -24,73 +24,229 @@ class OverviewPage extends StatelessWidget { ), body: SingleChildScrollView( padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Row 1: High-Level KPIs - SizedBox( - height: 160, - child: Row( + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + // Breakpoints + final isDesktop = width > 1200; + final isTablet = width > 800 && width <= 1200; + + // Define card heights + const kpiHeight = 160.0; + const chartHeight = 350.0; + + if (isDesktop) { + // Desktop: 3 columns for KPIs, 2 columns for Charts/Lists + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: kpiHeight, + child: Row( + children: [ + Expanded( + child: AnalyticsCardSlot( + cardIds: const [KpiCardId.usersTotalRegistered], + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: AnalyticsCardSlot( + cardIds: const [ + KpiCardId.contentHeadlinesTotalViews, + ], + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: AnalyticsCardSlot( + cardIds: const [ + KpiCardId.engagementsReportsPending, + ], + ), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.lg), + SizedBox( + height: chartHeight, + child: Row( + children: [ + Expanded( + child: AnalyticsCardSlot( + cardIds: const [ + ChartCardId.usersRegistrationsOverTime, + ], + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: AnalyticsCardSlot( + cardIds: const [ + ChartCardId.contentHeadlinesViewsOverTime, + ], + ), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.lg), + SizedBox( + height: chartHeight, + child: Row( + children: [ + Expanded( + child: AnalyticsCardSlot( + cardIds: const [ + RankedListCardId.overviewHeadlinesMostViewed, + ], + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: AnalyticsCardSlot( + cardIds: const [ + RankedListCardId.overviewSourcesMostFollowed, + ], + ), + ), + ], + ), + ), + ], + ); + } else if (isTablet) { + // Tablet: 2 columns for everything + return Column( + children: [ + SizedBox( + height: kpiHeight, + child: Row( + children: [ + Expanded( + child: AnalyticsCardSlot( + cardIds: const [KpiCardId.usersTotalRegistered], + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: AnalyticsCardSlot( + cardIds: const [ + KpiCardId.contentHeadlinesTotalViews, + ], + ), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.md), + SizedBox( + height: kpiHeight, + child: AnalyticsCardSlot( + cardIds: const [KpiCardId.engagementsReportsPending], + ), + ), + const SizedBox(height: AppSpacing.lg), + SizedBox( + height: chartHeight, + child: Row( + children: [ + Expanded( + child: AnalyticsCardSlot( + cardIds: const [ + ChartCardId.usersRegistrationsOverTime, + ], + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: AnalyticsCardSlot( + cardIds: const [ + ChartCardId.contentHeadlinesViewsOverTime, + ], + ), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.lg), + SizedBox( + height: chartHeight, + child: Row( + children: [ + Expanded( + child: AnalyticsCardSlot( + cardIds: const [ + RankedListCardId.overviewHeadlinesMostViewed, + ], + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: AnalyticsCardSlot( + cardIds: const [ + RankedListCardId.overviewSourcesMostFollowed, + ], + ), + ), + ], + ), + ), + ], + ); + } else { + // Mobile: 1 column + return Column( children: [ - Expanded( + SizedBox( + height: kpiHeight, child: AnalyticsCardSlot( cardIds: const [KpiCardId.usersTotalRegistered], ), ), - const SizedBox(width: AppSpacing.md), - Expanded( + const SizedBox(height: AppSpacing.md), + SizedBox( + height: kpiHeight, child: AnalyticsCardSlot( cardIds: const [KpiCardId.contentHeadlinesTotalViews], ), ), - const SizedBox(width: AppSpacing.md), - Expanded( + const SizedBox(height: AppSpacing.md), + SizedBox( + height: kpiHeight, child: AnalyticsCardSlot( cardIds: const [KpiCardId.engagementsReportsPending], ), ), - ], - ), - ), - const SizedBox(height: AppSpacing.lg), - - // Row 2: Primary Trends (Charts) - SizedBox( - height: 350, - child: Row( - children: [ - Expanded( + const SizedBox(height: AppSpacing.lg), + SizedBox( + height: chartHeight, child: AnalyticsCardSlot( cardIds: const [ChartCardId.usersRegistrationsOverTime], ), ), - const SizedBox(width: AppSpacing.md), - Expanded( + const SizedBox(height: AppSpacing.md), + SizedBox( + height: chartHeight, child: AnalyticsCardSlot( cardIds: const [ ChartCardId.contentHeadlinesViewsOverTime, ], ), ), - ], - ), - ), - const SizedBox(height: AppSpacing.lg), - - // Row 3: Top Performers (Ranked Lists) - SizedBox( - height: 350, - child: Row( - children: [ - Expanded( + const SizedBox(height: AppSpacing.lg), + SizedBox( + height: chartHeight, child: AnalyticsCardSlot( cardIds: const [ RankedListCardId.overviewHeadlinesMostViewed, ], ), ), - const SizedBox(width: AppSpacing.md), - Expanded( + const SizedBox(height: AppSpacing.md), + SizedBox( + height: chartHeight, child: AnalyticsCardSlot( cardIds: const [ RankedListCardId.overviewSourcesMostFollowed, @@ -98,9 +254,9 @@ class OverviewPage extends StatelessWidget { ), ), ], - ), - ), - ], + ); + } + }, ), ), ); From 1d3bdeb086385b55693604b23e6cba8cd363dba1 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 08:58:06 +0100 Subject: [PATCH 42/59] feat(localization): add new AR and EN translations for dashboard features - Added Arabic (AR) translations for various dashboard features in app_ar.arb - Added English (EN) translations for various dashboard features in app_en.arb --- lib/l10n/arb/app_ar.arb | 208 +++++++++++++++++++++++++++++++++++++++ lib/l10n/arb/app_en.arb | 210 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 417 insertions(+), 1 deletion(-) diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 0c5b0284..870c142e 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -3368,5 +3368,213 @@ "vsPreviousPeriod": "مقارنة بالفترة السابقة", "@vsPreviousPeriod": { "description": "Label indicating the trend comparison context" + }, + "vsPreviousDay": "مقارنة بـ ٢٤ ساعة الماضية", + "@vsPreviousDay": { + "description": "Label indicating the trend comparison context for the last 24 hours" + }, + "vsPreviousWeek": "مقارنة بـ ٧ أيام الماضية", + "@vsPreviousWeek": { + "description": "Label indicating the trend comparison context for the last 7 days" + }, + "vsPreviousMonth": "مقارنة بـ ٣٠ يوم الماضية", + "@vsPreviousMonth": { + "description": "Label indicating the trend comparison context for the last 30 days" + }, + "vsPreviousYear": "مقارنة بالسنة الماضية", + "@vsPreviousYear": { + "description": "Label indicating the trend comparison context for the last year" + }, + "kpiUsersTotalRegistered": "إجمالي المستخدمين المسجلين", + "@kpiUsersTotalRegistered": { + "description": "Title for the Total Registered Users KPI card" + }, + "kpiUsersNewRegistrations": "تسجيلات جديدة", + "@kpiUsersNewRegistrations": { + "description": "Title for the New Registrations KPI card" + }, + "kpiUsersActiveUsers": "المستخدمون النشطون", + "@kpiUsersActiveUsers": { + "description": "Title for the Active Users KPI card" + }, + "kpiContentHeadlinesTotalPublished": "إجمالي العناوين المنشورة", + "@kpiContentHeadlinesTotalPublished": { + "description": "Title for the Total Headlines Published KPI card" + }, + "kpiContentHeadlinesTotalViews": "إجمالي مشاهدات العناوين", + "@kpiContentHeadlinesTotalViews": { + "description": "Title for the Total Headline Views KPI card" + }, + "kpiContentHeadlinesTotalLikes": "إجمالي الإعجابات", + "@kpiContentHeadlinesTotalLikes": { + "description": "Title for the Total Headline Likes KPI card" + }, + "kpiContentSourcesTotalSources": "إجمالي المصادر", + "@kpiContentSourcesTotalSources": { + "description": "Title for the Total Sources KPI card" + }, + "kpiContentSourcesNewSources": "مصادر جديدة", + "@kpiContentSourcesNewSources": { + "description": "Title for the New Sources KPI card" + }, + "kpiContentSourcesTotalFollowers": "إجمالي متابعي المصادر", + "@kpiContentSourcesTotalFollowers": { + "description": "Title for the Total Source Followers KPI card" + }, + "kpiContentTopicsTotalTopics": "إجمالي المواضيع", + "@kpiContentTopicsTotalTopics": { + "description": "Title for the Total Topics KPI card" + }, + "kpiContentTopicsNewTopics": "مواضيع جديدة", + "@kpiContentTopicsNewTopics": { + "description": "Title for the New Topics KPI card" + }, + "kpiContentTopicsTotalFollowers": "إجمالي متابعي المواضيع", + "@kpiContentTopicsTotalFollowers": { + "description": "Title for the Total Topic Followers KPI card" + }, + "kpiEngagementsTotalReactions": "إجمالي التفاعلات", + "@kpiEngagementsTotalReactions": { + "description": "Title for the Total Reactions KPI card" + }, + "kpiEngagementsTotalComments": "إجمالي التعليقات", + "@kpiEngagementsTotalComments": { + "description": "Title for the Total Comments KPI card" + }, + "kpiEngagementsAverageEngagementRate": "معدل التفاعل", + "@kpiEngagementsAverageEngagementRate": { + "description": "Title for the Average Engagement Rate KPI card" + }, + "kpiEngagementsReportsPending": "بلاغات قيد الانتظار", + "@kpiEngagementsReportsPending": { + "description": "Title for the Pending Reports KPI card" + }, + "kpiEngagementsReportsResolved": "بلاغات تم حلها", + "@kpiEngagementsReportsResolved": { + "description": "Title for the Resolved Reports KPI card" + }, + "kpiEngagementsReportsAverageResolutionTime": "متوسط وقت الحل", + "@kpiEngagementsReportsAverageResolutionTime": { + "description": "Title for the Average Resolution Time KPI card" + }, + "kpiEngagementsAppReviewsTotalFeedback": "إجمالي الملاحظات", + "@kpiEngagementsAppReviewsTotalFeedback": { + "description": "Title for the Total Feedback KPI card" + }, + "kpiEngagementsAppReviewsPositiveFeedback": "ملاحظات إيجابية", + "@kpiEngagementsAppReviewsPositiveFeedback": { + "description": "Title for the Positive Feedback KPI card" + }, + "kpiEngagementsAppReviewsStoreRequests": "طلبات تقييم المتجر", + "@kpiEngagementsAppReviewsStoreRequests": { + "description": "Title for the Store Review Requests KPI card" + }, + "chartUsersRegistrationsOverTime": "التسجيلات عبر الزمن", + "@chartUsersRegistrationsOverTime": { + "description": "Title for the Registrations Over Time chart card" + }, + "chartUsersActiveUsersOverTime": "اتجاه المستخدمين النشطين", + "@chartUsersActiveUsersOverTime": { + "description": "Title for the Active Users Trend chart card" + }, + "chartUsersRoleDistribution": "توزيع أدوار المستخدمين", + "@chartUsersRoleDistribution": { + "description": "Title for the User Role Distribution chart card" + }, + "chartContentHeadlinesViewsOverTime": "اتجاه المشاهدات", + "@chartContentHeadlinesViewsOverTime": { + "description": "Title for the Headline Views Trend chart card" + }, + "chartContentHeadlinesLikesOverTime": "اتجاه الإعجابات", + "@chartContentHeadlinesLikesOverTime": { + "description": "Title for the Headline Likes Trend chart card" + }, + "chartContentHeadlinesViewsByTopic": "المشاهدات حسب الموضوع", + "@chartContentHeadlinesViewsByTopic": { + "description": "Title for the Views by Topic chart card" + }, + "chartContentSourcesHeadlinesPublishedOverTime": "نشاط المصادر", + "@chartContentSourcesHeadlinesPublishedOverTime": { + "description": "Title for the Source Activity chart card" + }, + "chartContentSourcesFollowersOverTime": "نمو متابعي المصادر", + "@chartContentSourcesFollowersOverTime": { + "description": "Title for the Source Follower Growth chart card" + }, + "chartContentSourcesEngagementByType": "التفاعل حسب نوع المصدر", + "@chartContentSourcesEngagementByType": { + "description": "Title for the Engagement by Source Type chart card" + }, + "chartContentTopicsFollowersOverTime": "نمو متابعي المواضيع", + "@chartContentTopicsFollowersOverTime": { + "description": "Title for the Topic Follower Growth chart card" + }, + "chartContentTopicsHeadlinesPublishedOverTime": "نشاط المواضيع", + "@chartContentTopicsHeadlinesPublishedOverTime": { + "description": "Title for the Topic Activity chart card" + }, + "chartContentTopicsEngagementByTopic": "التفاعل حسب الموضوع", + "@chartContentTopicsEngagementByTopic": { + "description": "Title for the Engagement by Topic chart card" + }, + "chartEngagementsReactionsOverTime": "اتجاه التفاعلات", + "@chartEngagementsReactionsOverTime": { + "description": "Title for the Reactions Trend chart card" + }, + "chartEngagementsCommentsOverTime": "اتجاه التعليقات", + "@chartEngagementsCommentsOverTime": { + "description": "Title for the Comments Trend chart card" + }, + "chartEngagementsReactionsByType": "توزيع التفاعلات", + "@chartEngagementsReactionsByType": { + "description": "Title for the Reactions Distribution chart card" + }, + "chartEngagementsReportsSubmittedOverTime": "البلاغات المقدمة", + "@chartEngagementsReportsSubmittedOverTime": { + "description": "Title for the Reports Submitted chart card" + }, + "chartEngagementsReportsResolutionTimeOverTime": "اتجاه وقت الحل", + "@chartEngagementsReportsResolutionTimeOverTime": { + "description": "Title for the Resolution Time Trend chart card" + }, + "chartEngagementsReportsByReason": "البلاغات حسب السبب", + "@chartEngagementsReportsByReason": { + "description": "Title for the Reports by Reason chart card" + }, + "chartEngagementsAppReviewsFeedbackOverTime": "اتجاه الملاحظات", + "@chartEngagementsAppReviewsFeedbackOverTime": { + "description": "Title for the Feedback Trend chart card" + }, + "chartEngagementsAppReviewsPositiveVsNegative": "تحليل المشاعر", + "@chartEngagementsAppReviewsPositiveVsNegative": { + "description": "Title for the Sentiment Analysis chart card" + }, + "chartEngagementsAppReviewsStoreRequestsOverTime": "اتجاه طلبات المتجر", + "@chartEngagementsAppReviewsStoreRequestsOverTime": { + "description": "Title for the Store Requests Trend chart card" + }, + "chartContentSourcesStatusDistribution": "توزيع حالة المصادر", + "@chartContentSourcesStatusDistribution": { + "description": "Title for the Source Status Distribution chart card" + }, + "chartContentHeadlinesBreakingNewsDistribution": "توزيع الأخبار العاجلة", + "@chartContentHeadlinesBreakingNewsDistribution": { + "description": "Title for the Breaking News Distribution chart card" + }, + "rankedListOverviewHeadlinesMostViewed": "العناوين الأكثر مشاهدة", + "@rankedListOverviewHeadlinesMostViewed": { + "description": "Title for the Top Viewed Headlines ranked list card" + }, + "rankedListOverviewHeadlinesMostLiked": "العناوين الأكثر إعجاباً", + "@rankedListOverviewHeadlinesMostLiked": { + "description": "Title for the Top Liked Headlines ranked list card" + }, + "rankedListOverviewSourcesMostFollowed": "المصادر الأكثر متابعة", + "@rankedListOverviewSourcesMostFollowed": { + "description": "Title for the Top Followed Sources ranked list card" + }, + "rankedListOverviewTopicsMostFollowed": "المواضيع الأكثر متابعة", + "@rankedListOverviewTopicsMostFollowed": { + "description": "Title for the Top Followed Topics ranked list card" } } \ No newline at end of file diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 948a5fae..b39dbc41 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -3341,7 +3341,7 @@ "@analyticsEventSourceFilterUsedDescription": { "description": "Description for the Source Filter Usage analytics event" }, - "timeFrameDay": "24H", + "timeFrameDay": "24H", "@timeFrameDay": { "description": "Label for 24 hour time frame toggle" }, @@ -3364,5 +3364,213 @@ "vsPreviousPeriod": "vs previous period", "@vsPreviousPeriod": { "description": "Label indicating the trend comparison context" + }, + "vsPreviousDay": "vs previous 24h", + "@vsPreviousDay": { + "description": "Label indicating the trend comparison context for the last 24 hours" + }, + "vsPreviousWeek": "vs previous 7 days", + "@vsPreviousWeek": { + "description": "Label indicating the trend comparison context for the last 7 days" + }, + "vsPreviousMonth": "vs previous 30 days", + "@vsPreviousMonth": { + "description": "Label indicating the trend comparison context for the last 30 days" + }, + "vsPreviousYear": "vs previous year", + "@vsPreviousYear": { + "description": "Label indicating the trend comparison context for the last year" + }, + "kpiUsersTotalRegistered": "Total Registered Users", + "@kpiUsersTotalRegistered": { + "description": "Title for the Total Registered Users KPI card" + }, + "kpiUsersNewRegistrations": "New Registrations", + "@kpiUsersNewRegistrations": { + "description": "Title for the New Registrations KPI card" + }, + "kpiUsersActiveUsers": "Active Users", + "@kpiUsersActiveUsers": { + "description": "Title for the Active Users KPI card" + }, + "kpiContentHeadlinesTotalPublished": "Total Headlines Published", + "@kpiContentHeadlinesTotalPublished": { + "description": "Title for the Total Headlines Published KPI card" + }, + "kpiContentHeadlinesTotalViews": "Total Headline Views", + "@kpiContentHeadlinesTotalViews": { + "description": "Title for the Total Headline Views KPI card" + }, + "kpiContentHeadlinesTotalLikes": "Total Headline Likes", + "@kpiContentHeadlinesTotalLikes": { + "description": "Title for the Total Headline Likes KPI card" + }, + "kpiContentSourcesTotalSources": "Total Sources", + "@kpiContentSourcesTotalSources": { + "description": "Title for the Total Sources KPI card" + }, + "kpiContentSourcesNewSources": "New Sources", + "@kpiContentSourcesNewSources": { + "description": "Title for the New Sources KPI card" + }, + "kpiContentSourcesTotalFollowers": "Total Source Followers", + "@kpiContentSourcesTotalFollowers": { + "description": "Title for the Total Source Followers KPI card" + }, + "kpiContentTopicsTotalTopics": "Total Topics", + "@kpiContentTopicsTotalTopics": { + "description": "Title for the Total Topics KPI card" + }, + "kpiContentTopicsNewTopics": "New Topics", + "@kpiContentTopicsNewTopics": { + "description": "Title for the New Topics KPI card" + }, + "kpiContentTopicsTotalFollowers": "Total Topic Followers", + "@kpiContentTopicsTotalFollowers": { + "description": "Title for the Total Topic Followers KPI card" + }, + "kpiEngagementsTotalReactions": "Total Reactions", + "@kpiEngagementsTotalReactions": { + "description": "Title for the Total Reactions KPI card" + }, + "kpiEngagementsTotalComments": "Total Comments", + "@kpiEngagementsTotalComments": { + "description": "Title for the Total Comments KPI card" + }, + "kpiEngagementsAverageEngagementRate": "Avg. Engagement Rate", + "@kpiEngagementsAverageEngagementRate": { + "description": "Title for the Average Engagement Rate KPI card" + }, + "kpiEngagementsReportsPending": "Pending Reports", + "@kpiEngagementsReportsPending": { + "description": "Title for the Pending Reports KPI card" + }, + "kpiEngagementsReportsResolved": "Resolved Reports", + "@kpiEngagementsReportsResolved": { + "description": "Title for the Resolved Reports KPI card" + }, + "kpiEngagementsReportsAverageResolutionTime": "Avg. Resolution Time", + "@kpiEngagementsReportsAverageResolutionTime": { + "description": "Title for the Average Resolution Time KPI card" + }, + "kpiEngagementsAppReviewsTotalFeedback": "Total Feedback", + "@kpiEngagementsAppReviewsTotalFeedback": { + "description": "Title for the Total Feedback KPI card" + }, + "kpiEngagementsAppReviewsPositiveFeedback": "Positive Feedback", + "@kpiEngagementsAppReviewsPositiveFeedback": { + "description": "Title for the Positive Feedback KPI card" + }, + "kpiEngagementsAppReviewsStoreRequests": "Store Review Requests", + "@kpiEngagementsAppReviewsStoreRequests": { + "description": "Title for the Store Review Requests KPI card" + }, + "chartUsersRegistrationsOverTime": "Registrations Over Time", + "@chartUsersRegistrationsOverTime": { + "description": "Title for the Registrations Over Time chart card" + }, + "chartUsersActiveUsersOverTime": "Active Users Trend", + "@chartUsersActiveUsersOverTime": { + "description": "Title for the Active Users Trend chart card" + }, + "chartUsersRoleDistribution": "User Role Distribution", + "@chartUsersRoleDistribution": { + "description": "Title for the User Role Distribution chart card" + }, + "chartContentHeadlinesViewsOverTime": "Headline Views Trend", + "@chartContentHeadlinesViewsOverTime": { + "description": "Title for the Headline Views Trend chart card" + }, + "chartContentHeadlinesLikesOverTime": "Headline Likes Trend", + "@chartContentHeadlinesLikesOverTime": { + "description": "Title for the Headline Likes Trend chart card" + }, + "chartContentHeadlinesViewsByTopic": "Views by Topic", + "@chartContentHeadlinesViewsByTopic": { + "description": "Title for the Views by Topic chart card" + }, + "chartContentSourcesHeadlinesPublishedOverTime": "Source Activity", + "@chartContentSourcesHeadlinesPublishedOverTime": { + "description": "Title for the Source Activity chart card" + }, + "chartContentSourcesFollowersOverTime": "Source Follower Growth", + "@chartContentSourcesFollowersOverTime": { + "description": "Title for the Source Follower Growth chart card" + }, + "chartContentSourcesEngagementByType": "Engagement by Source Type", + "@chartContentSourcesEngagementByType": { + "description": "Title for the Engagement by Source Type chart card" + }, + "chartContentTopicsFollowersOverTime": "Topic Follower Growth", + "@chartContentTopicsFollowersOverTime": { + "description": "Title for the Topic Follower Growth chart card" + }, + "chartContentTopicsHeadlinesPublishedOverTime": "Topic Activity", + "@chartContentTopicsHeadlinesPublishedOverTime": { + "description": "Title for the Topic Activity chart card" + }, + "chartContentTopicsEngagementByTopic": "Engagement by Topic", + "@chartContentTopicsEngagementByTopic": { + "description": "Title for the Engagement by Topic chart card" + }, + "chartEngagementsReactionsOverTime": "Reactions Trend", + "@chartEngagementsReactionsOverTime": { + "description": "Title for the Reactions Trend chart card" + }, + "chartEngagementsCommentsOverTime": "Comments Trend", + "@chartEngagementsCommentsOverTime": { + "description": "Title for the Comments Trend chart card" + }, + "chartEngagementsReactionsByType": "Reactions Distribution", + "@chartEngagementsReactionsByType": { + "description": "Title for the Reactions Distribution chart card" + }, + "chartEngagementsReportsSubmittedOverTime": "Reports Submitted", + "@chartEngagementsReportsSubmittedOverTime": { + "description": "Title for the Reports Submitted chart card" + }, + "chartEngagementsReportsResolutionTimeOverTime": "Resolution Time Trend", + "@chartEngagementsReportsResolutionTimeOverTime": { + "description": "Title for the Resolution Time Trend chart card" + }, + "chartEngagementsReportsByReason": "Reports by Reason", + "@chartEngagementsReportsByReason": { + "description": "Title for the Reports by Reason chart card" + }, + "chartEngagementsAppReviewsFeedbackOverTime": "Feedback Trend", + "@chartEngagementsAppReviewsFeedbackOverTime": { + "description": "Title for the Feedback Trend chart card" + }, + "chartEngagementsAppReviewsPositiveVsNegative": "Sentiment Analysis", + "@chartEngagementsAppReviewsPositiveVsNegative": { + "description": "Title for the Sentiment Analysis chart card" + }, + "chartEngagementsAppReviewsStoreRequestsOverTime": "Store Requests Trend", + "@chartEngagementsAppReviewsStoreRequestsOverTime": { + "description": "Title for the Store Requests Trend chart card" + }, + "chartContentSourcesStatusDistribution": "Source Status Distribution", + "@chartContentSourcesStatusDistribution": { + "description": "Title for the Source Status Distribution chart card" + }, + "chartContentHeadlinesBreakingNewsDistribution": "Breaking News Distribution", + "@chartContentHeadlinesBreakingNewsDistribution": { + "description": "Title for the Breaking News Distribution chart card" + }, + "rankedListOverviewHeadlinesMostViewed": "Top Viewed Headlines", + "@rankedListOverviewHeadlinesMostViewed": { + "description": "Title for the Top Viewed Headlines ranked list card" + }, + "rankedListOverviewHeadlinesMostLiked": "Top Liked Headlines", + "@rankedListOverviewHeadlinesMostLiked": { + "description": "Title for the Top Liked Headlines ranked list card" + }, + "rankedListOverviewSourcesMostFollowed": "Top Followed Sources", + "@rankedListOverviewSourcesMostFollowed": { + "description": "Title for the Top Followed Sources ranked list card" + }, + "rankedListOverviewTopicsMostFollowed": "Top Followed Topics", + "@rankedListOverviewTopicsMostFollowed": { + "description": "Title for the Top Followed Topics ranked list card" } } \ No newline at end of file From 2e78fe874aed06bb44c7a888985c58744f4f47bc Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 08:58:23 +0100 Subject: [PATCH 43/59] build(serialization): sync --- lib/l10n/app_localizations.dart | 312 +++++++++++++++++++++++++++++ lib/l10n/app_localizations_ar.dart | 158 +++++++++++++++ lib/l10n/app_localizations_en.dart | 162 +++++++++++++++ 3 files changed, 632 insertions(+) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 125ef0fc..15d0470a 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4885,6 +4885,318 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'vs previous period'** String get vsPreviousPeriod; + + /// Label indicating the trend comparison context for the last 24 hours + /// + /// In en, this message translates to: + /// **'vs previous 24h'** + String get vsPreviousDay; + + /// Label indicating the trend comparison context for the last 7 days + /// + /// In en, this message translates to: + /// **'vs previous 7 days'** + String get vsPreviousWeek; + + /// Label indicating the trend comparison context for the last 30 days + /// + /// In en, this message translates to: + /// **'vs previous 30 days'** + String get vsPreviousMonth; + + /// Label indicating the trend comparison context for the last year + /// + /// In en, this message translates to: + /// **'vs previous year'** + String get vsPreviousYear; + + /// Title for the Total Registered Users KPI card + /// + /// In en, this message translates to: + /// **'Total Registered Users'** + String get kpiUsersTotalRegistered; + + /// Title for the New Registrations KPI card + /// + /// In en, this message translates to: + /// **'New Registrations'** + String get kpiUsersNewRegistrations; + + /// Title for the Active Users KPI card + /// + /// In en, this message translates to: + /// **'Active Users'** + String get kpiUsersActiveUsers; + + /// Title for the Total Headlines Published KPI card + /// + /// In en, this message translates to: + /// **'Total Headlines Published'** + String get kpiContentHeadlinesTotalPublished; + + /// Title for the Total Headline Views KPI card + /// + /// In en, this message translates to: + /// **'Total Headline Views'** + String get kpiContentHeadlinesTotalViews; + + /// Title for the Total Headline Likes KPI card + /// + /// In en, this message translates to: + /// **'Total Headline Likes'** + String get kpiContentHeadlinesTotalLikes; + + /// Title for the Total Sources KPI card + /// + /// In en, this message translates to: + /// **'Total Sources'** + String get kpiContentSourcesTotalSources; + + /// Title for the New Sources KPI card + /// + /// In en, this message translates to: + /// **'New Sources'** + String get kpiContentSourcesNewSources; + + /// Title for the Total Source Followers KPI card + /// + /// In en, this message translates to: + /// **'Total Source Followers'** + String get kpiContentSourcesTotalFollowers; + + /// Title for the Total Topics KPI card + /// + /// In en, this message translates to: + /// **'Total Topics'** + String get kpiContentTopicsTotalTopics; + + /// Title for the New Topics KPI card + /// + /// In en, this message translates to: + /// **'New Topics'** + String get kpiContentTopicsNewTopics; + + /// Title for the Total Topic Followers KPI card + /// + /// In en, this message translates to: + /// **'Total Topic Followers'** + String get kpiContentTopicsTotalFollowers; + + /// Title for the Total Reactions KPI card + /// + /// In en, this message translates to: + /// **'Total Reactions'** + String get kpiEngagementsTotalReactions; + + /// Title for the Total Comments KPI card + /// + /// In en, this message translates to: + /// **'Total Comments'** + String get kpiEngagementsTotalComments; + + /// Title for the Average Engagement Rate KPI card + /// + /// In en, this message translates to: + /// **'Avg. Engagement Rate'** + String get kpiEngagementsAverageEngagementRate; + + /// Title for the Pending Reports KPI card + /// + /// In en, this message translates to: + /// **'Pending Reports'** + String get kpiEngagementsReportsPending; + + /// Title for the Resolved Reports KPI card + /// + /// In en, this message translates to: + /// **'Resolved Reports'** + String get kpiEngagementsReportsResolved; + + /// Title for the Average Resolution Time KPI card + /// + /// In en, this message translates to: + /// **'Avg. Resolution Time'** + String get kpiEngagementsReportsAverageResolutionTime; + + /// Title for the Total Feedback KPI card + /// + /// In en, this message translates to: + /// **'Total Feedback'** + String get kpiEngagementsAppReviewsTotalFeedback; + + /// Title for the Positive Feedback KPI card + /// + /// In en, this message translates to: + /// **'Positive Feedback'** + String get kpiEngagementsAppReviewsPositiveFeedback; + + /// Title for the Store Review Requests KPI card + /// + /// In en, this message translates to: + /// **'Store Review Requests'** + String get kpiEngagementsAppReviewsStoreRequests; + + /// Title for the Registrations Over Time chart card + /// + /// In en, this message translates to: + /// **'Registrations Over Time'** + String get chartUsersRegistrationsOverTime; + + /// Title for the Active Users Trend chart card + /// + /// In en, this message translates to: + /// **'Active Users Trend'** + String get chartUsersActiveUsersOverTime; + + /// Title for the User Role Distribution chart card + /// + /// In en, this message translates to: + /// **'User Role Distribution'** + String get chartUsersRoleDistribution; + + /// Title for the Headline Views Trend chart card + /// + /// In en, this message translates to: + /// **'Headline Views Trend'** + String get chartContentHeadlinesViewsOverTime; + + /// Title for the Headline Likes Trend chart card + /// + /// In en, this message translates to: + /// **'Headline Likes Trend'** + String get chartContentHeadlinesLikesOverTime; + + /// Title for the Views by Topic chart card + /// + /// In en, this message translates to: + /// **'Views by Topic'** + String get chartContentHeadlinesViewsByTopic; + + /// Title for the Source Activity chart card + /// + /// In en, this message translates to: + /// **'Source Activity'** + String get chartContentSourcesHeadlinesPublishedOverTime; + + /// Title for the Source Follower Growth chart card + /// + /// In en, this message translates to: + /// **'Source Follower Growth'** + String get chartContentSourcesFollowersOverTime; + + /// Title for the Engagement by Source Type chart card + /// + /// In en, this message translates to: + /// **'Engagement by Source Type'** + String get chartContentSourcesEngagementByType; + + /// Title for the Topic Follower Growth chart card + /// + /// In en, this message translates to: + /// **'Topic Follower Growth'** + String get chartContentTopicsFollowersOverTime; + + /// Title for the Topic Activity chart card + /// + /// In en, this message translates to: + /// **'Topic Activity'** + String get chartContentTopicsHeadlinesPublishedOverTime; + + /// Title for the Engagement by Topic chart card + /// + /// In en, this message translates to: + /// **'Engagement by Topic'** + String get chartContentTopicsEngagementByTopic; + + /// Title for the Reactions Trend chart card + /// + /// In en, this message translates to: + /// **'Reactions Trend'** + String get chartEngagementsReactionsOverTime; + + /// Title for the Comments Trend chart card + /// + /// In en, this message translates to: + /// **'Comments Trend'** + String get chartEngagementsCommentsOverTime; + + /// Title for the Reactions Distribution chart card + /// + /// In en, this message translates to: + /// **'Reactions Distribution'** + String get chartEngagementsReactionsByType; + + /// Title for the Reports Submitted chart card + /// + /// In en, this message translates to: + /// **'Reports Submitted'** + String get chartEngagementsReportsSubmittedOverTime; + + /// Title for the Resolution Time Trend chart card + /// + /// In en, this message translates to: + /// **'Resolution Time Trend'** + String get chartEngagementsReportsResolutionTimeOverTime; + + /// Title for the Reports by Reason chart card + /// + /// In en, this message translates to: + /// **'Reports by Reason'** + String get chartEngagementsReportsByReason; + + /// Title for the Feedback Trend chart card + /// + /// In en, this message translates to: + /// **'Feedback Trend'** + String get chartEngagementsAppReviewsFeedbackOverTime; + + /// Title for the Sentiment Analysis chart card + /// + /// In en, this message translates to: + /// **'Sentiment Analysis'** + String get chartEngagementsAppReviewsPositiveVsNegative; + + /// Title for the Store Requests Trend chart card + /// + /// In en, this message translates to: + /// **'Store Requests Trend'** + String get chartEngagementsAppReviewsStoreRequestsOverTime; + + /// Title for the Source Status Distribution chart card + /// + /// In en, this message translates to: + /// **'Source Status Distribution'** + String get chartContentSourcesStatusDistribution; + + /// Title for the Breaking News Distribution chart card + /// + /// In en, this message translates to: + /// **'Breaking News Distribution'** + String get chartContentHeadlinesBreakingNewsDistribution; + + /// Title for the Top Viewed Headlines ranked list card + /// + /// In en, this message translates to: + /// **'Top Viewed Headlines'** + String get rankedListOverviewHeadlinesMostViewed; + + /// Title for the Top Liked Headlines ranked list card + /// + /// In en, this message translates to: + /// **'Top Liked Headlines'** + String get rankedListOverviewHeadlinesMostLiked; + + /// Title for the Top Followed Sources ranked list card + /// + /// In en, this message translates to: + /// **'Top Followed Sources'** + String get rankedListOverviewSourcesMostFollowed; + + /// Title for the Top Followed Topics ranked list card + /// + /// In en, this message translates to: + /// **'Top Followed Topics'** + String get rankedListOverviewTopicsMostFollowed; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index 9edb424d..8cf8c08c 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -2686,4 +2686,162 @@ class AppLocalizationsAr extends AppLocalizations { @override String get vsPreviousPeriod => 'مقارنة بالفترة السابقة'; + + @override + String get vsPreviousDay => 'مقارنة بـ ٢٤ ساعة الماضية'; + + @override + String get vsPreviousWeek => 'مقارنة بـ ٧ أيام الماضية'; + + @override + String get vsPreviousMonth => 'مقارنة بـ ٣٠ يوم الماضية'; + + @override + String get vsPreviousYear => 'مقارنة بالسنة الماضية'; + + @override + String get kpiUsersTotalRegistered => 'إجمالي المستخدمين المسجلين'; + + @override + String get kpiUsersNewRegistrations => 'تسجيلات جديدة'; + + @override + String get kpiUsersActiveUsers => 'المستخدمون النشطون'; + + @override + String get kpiContentHeadlinesTotalPublished => 'إجمالي العناوين المنشورة'; + + @override + String get kpiContentHeadlinesTotalViews => 'إجمالي مشاهدات العناوين'; + + @override + String get kpiContentHeadlinesTotalLikes => 'إجمالي الإعجابات'; + + @override + String get kpiContentSourcesTotalSources => 'إجمالي المصادر'; + + @override + String get kpiContentSourcesNewSources => 'مصادر جديدة'; + + @override + String get kpiContentSourcesTotalFollowers => 'إجمالي متابعي المصادر'; + + @override + String get kpiContentTopicsTotalTopics => 'إجمالي المواضيع'; + + @override + String get kpiContentTopicsNewTopics => 'مواضيع جديدة'; + + @override + String get kpiContentTopicsTotalFollowers => 'إجمالي متابعي المواضيع'; + + @override + String get kpiEngagementsTotalReactions => 'إجمالي التفاعلات'; + + @override + String get kpiEngagementsTotalComments => 'إجمالي التعليقات'; + + @override + String get kpiEngagementsAverageEngagementRate => 'معدل التفاعل'; + + @override + String get kpiEngagementsReportsPending => 'بلاغات قيد الانتظار'; + + @override + String get kpiEngagementsReportsResolved => 'بلاغات تم حلها'; + + @override + String get kpiEngagementsReportsAverageResolutionTime => 'متوسط وقت الحل'; + + @override + String get kpiEngagementsAppReviewsTotalFeedback => 'إجمالي الملاحظات'; + + @override + String get kpiEngagementsAppReviewsPositiveFeedback => 'ملاحظات إيجابية'; + + @override + String get kpiEngagementsAppReviewsStoreRequests => 'طلبات تقييم المتجر'; + + @override + String get chartUsersRegistrationsOverTime => 'التسجيلات عبر الزمن'; + + @override + String get chartUsersActiveUsersOverTime => 'اتجاه المستخدمين النشطين'; + + @override + String get chartUsersRoleDistribution => 'توزيع أدوار المستخدمين'; + + @override + String get chartContentHeadlinesViewsOverTime => 'اتجاه المشاهدات'; + + @override + String get chartContentHeadlinesLikesOverTime => 'اتجاه الإعجابات'; + + @override + String get chartContentHeadlinesViewsByTopic => 'المشاهدات حسب الموضوع'; + + @override + String get chartContentSourcesHeadlinesPublishedOverTime => 'نشاط المصادر'; + + @override + String get chartContentSourcesFollowersOverTime => 'نمو متابعي المصادر'; + + @override + String get chartContentSourcesEngagementByType => 'التفاعل حسب نوع المصدر'; + + @override + String get chartContentTopicsFollowersOverTime => 'نمو متابعي المواضيع'; + + @override + String get chartContentTopicsHeadlinesPublishedOverTime => 'نشاط المواضيع'; + + @override + String get chartContentTopicsEngagementByTopic => 'التفاعل حسب الموضوع'; + + @override + String get chartEngagementsReactionsOverTime => 'اتجاه التفاعلات'; + + @override + String get chartEngagementsCommentsOverTime => 'اتجاه التعليقات'; + + @override + String get chartEngagementsReactionsByType => 'توزيع التفاعلات'; + + @override + String get chartEngagementsReportsSubmittedOverTime => 'البلاغات المقدمة'; + + @override + String get chartEngagementsReportsResolutionTimeOverTime => 'اتجاه وقت الحل'; + + @override + String get chartEngagementsReportsByReason => 'البلاغات حسب السبب'; + + @override + String get chartEngagementsAppReviewsFeedbackOverTime => 'اتجاه الملاحظات'; + + @override + String get chartEngagementsAppReviewsPositiveVsNegative => 'تحليل المشاعر'; + + @override + String get chartEngagementsAppReviewsStoreRequestsOverTime => + 'اتجاه طلبات المتجر'; + + @override + String get chartContentSourcesStatusDistribution => 'توزيع حالة المصادر'; + + @override + String get chartContentHeadlinesBreakingNewsDistribution => + 'توزيع الأخبار العاجلة'; + + @override + String get rankedListOverviewHeadlinesMostViewed => 'العناوين الأكثر مشاهدة'; + + @override + String get rankedListOverviewHeadlinesMostLiked => 'العناوين الأكثر إعجاباً'; + + @override + String get rankedListOverviewSourcesMostFollowed => 'المصادر الأكثر متابعة'; + + @override + String get rankedListOverviewTopicsMostFollowed => 'المواضيع الأكثر متابعة'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 217bc0dd..1403ccb3 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2696,4 +2696,166 @@ class AppLocalizationsEn extends AppLocalizations { @override String get vsPreviousPeriod => 'vs previous period'; + + @override + String get vsPreviousDay => 'vs previous 24h'; + + @override + String get vsPreviousWeek => 'vs previous 7 days'; + + @override + String get vsPreviousMonth => 'vs previous 30 days'; + + @override + String get vsPreviousYear => 'vs previous year'; + + @override + String get kpiUsersTotalRegistered => 'Total Registered Users'; + + @override + String get kpiUsersNewRegistrations => 'New Registrations'; + + @override + String get kpiUsersActiveUsers => 'Active Users'; + + @override + String get kpiContentHeadlinesTotalPublished => 'Total Headlines Published'; + + @override + String get kpiContentHeadlinesTotalViews => 'Total Headline Views'; + + @override + String get kpiContentHeadlinesTotalLikes => 'Total Headline Likes'; + + @override + String get kpiContentSourcesTotalSources => 'Total Sources'; + + @override + String get kpiContentSourcesNewSources => 'New Sources'; + + @override + String get kpiContentSourcesTotalFollowers => 'Total Source Followers'; + + @override + String get kpiContentTopicsTotalTopics => 'Total Topics'; + + @override + String get kpiContentTopicsNewTopics => 'New Topics'; + + @override + String get kpiContentTopicsTotalFollowers => 'Total Topic Followers'; + + @override + String get kpiEngagementsTotalReactions => 'Total Reactions'; + + @override + String get kpiEngagementsTotalComments => 'Total Comments'; + + @override + String get kpiEngagementsAverageEngagementRate => 'Avg. Engagement Rate'; + + @override + String get kpiEngagementsReportsPending => 'Pending Reports'; + + @override + String get kpiEngagementsReportsResolved => 'Resolved Reports'; + + @override + String get kpiEngagementsReportsAverageResolutionTime => + 'Avg. Resolution Time'; + + @override + String get kpiEngagementsAppReviewsTotalFeedback => 'Total Feedback'; + + @override + String get kpiEngagementsAppReviewsPositiveFeedback => 'Positive Feedback'; + + @override + String get kpiEngagementsAppReviewsStoreRequests => 'Store Review Requests'; + + @override + String get chartUsersRegistrationsOverTime => 'Registrations Over Time'; + + @override + String get chartUsersActiveUsersOverTime => 'Active Users Trend'; + + @override + String get chartUsersRoleDistribution => 'User Role Distribution'; + + @override + String get chartContentHeadlinesViewsOverTime => 'Headline Views Trend'; + + @override + String get chartContentHeadlinesLikesOverTime => 'Headline Likes Trend'; + + @override + String get chartContentHeadlinesViewsByTopic => 'Views by Topic'; + + @override + String get chartContentSourcesHeadlinesPublishedOverTime => 'Source Activity'; + + @override + String get chartContentSourcesFollowersOverTime => 'Source Follower Growth'; + + @override + String get chartContentSourcesEngagementByType => 'Engagement by Source Type'; + + @override + String get chartContentTopicsFollowersOverTime => 'Topic Follower Growth'; + + @override + String get chartContentTopicsHeadlinesPublishedOverTime => 'Topic Activity'; + + @override + String get chartContentTopicsEngagementByTopic => 'Engagement by Topic'; + + @override + String get chartEngagementsReactionsOverTime => 'Reactions Trend'; + + @override + String get chartEngagementsCommentsOverTime => 'Comments Trend'; + + @override + String get chartEngagementsReactionsByType => 'Reactions Distribution'; + + @override + String get chartEngagementsReportsSubmittedOverTime => 'Reports Submitted'; + + @override + String get chartEngagementsReportsResolutionTimeOverTime => + 'Resolution Time Trend'; + + @override + String get chartEngagementsReportsByReason => 'Reports by Reason'; + + @override + String get chartEngagementsAppReviewsFeedbackOverTime => 'Feedback Trend'; + + @override + String get chartEngagementsAppReviewsPositiveVsNegative => + 'Sentiment Analysis'; + + @override + String get chartEngagementsAppReviewsStoreRequestsOverTime => + 'Store Requests Trend'; + + @override + String get chartContentSourcesStatusDistribution => + 'Source Status Distribution'; + + @override + String get chartContentHeadlinesBreakingNewsDistribution => + 'Breaking News Distribution'; + + @override + String get rankedListOverviewHeadlinesMostViewed => 'Top Viewed Headlines'; + + @override + String get rankedListOverviewHeadlinesMostLiked => 'Top Liked Headlines'; + + @override + String get rankedListOverviewSourcesMostFollowed => 'Top Followed Sources'; + + @override + String get rankedListOverviewTopicsMostFollowed => 'Top Followed Topics'; } From 45018e8223acb7d57ba6b492b9c393ac8ad2bf2f Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 08:58:35 +0100 Subject: [PATCH 44/59] feat(analytics): add localized titles to chart cards - Replace static title with localized title in ChartCard widget - Add _getLocalizedTitle method to map ChartCardId to localized string - Implement switch statement for all ChartCardId cases - Update chart cards to use new localized titles --- lib/shared/widgets/analytics/chart_card.dart | 49 +++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/lib/shared/widgets/analytics/chart_card.dart b/lib/shared/widgets/analytics/chart_card.dart index b16b36b9..95d8d4cc 100644 --- a/lib/shared/widgets/analytics/chart_card.dart +++ b/lib/shared/widgets/analytics/chart_card.dart @@ -38,7 +38,7 @@ class _ChartCardState extends State { final currentPoints = widget.data.timeFrames[_selectedTimeFrame]; return AnalyticsCardShell( - title: widget.data.label, + title: _getLocalizedTitle(widget.data.id, l10n), currentSlot: widget.slotIndex, totalSlots: widget.totalSlots, onSlotChanged: widget.onSlotChanged, @@ -66,6 +66,53 @@ class _ChartCardState extends State { ); } + String _getLocalizedTitle(ChartCardId id, AppLocalizations l10n) { + switch (id) { + case ChartCardId.usersRegistrationsOverTime: + return l10n.chartUsersRegistrationsOverTime; + case ChartCardId.usersActiveUsersOverTime: + return l10n.chartUsersActiveUsersOverTime; + case ChartCardId.usersRoleDistribution: + return l10n.chartUsersRoleDistribution; + case ChartCardId.contentHeadlinesViewsOverTime: + return l10n.chartContentHeadlinesViewsOverTime; + case ChartCardId.contentHeadlinesLikesOverTime: + return l10n.chartContentHeadlinesLikesOverTime; + case ChartCardId.contentHeadlinesViewsByTopic: + return l10n.chartContentHeadlinesViewsByTopic; + case ChartCardId.contentSourcesHeadlinesPublishedOverTime: + return l10n.chartContentSourcesHeadlinesPublishedOverTime; + case ChartCardId.contentSourcesStatusDistribution: + return l10n.chartContentSourcesStatusDistribution; + case ChartCardId.contentSourcesEngagementByType: + return l10n.chartContentSourcesEngagementByType; + case ChartCardId.contentHeadlinesBreakingNewsDistribution: + return l10n.chartContentHeadlinesBreakingNewsDistribution; + case ChartCardId.contentTopicsHeadlinesPublishedOverTime: + return l10n.chartContentTopicsHeadlinesPublishedOverTime; + case ChartCardId.contentTopicsEngagementByTopic: + return l10n.chartContentTopicsEngagementByTopic; + case ChartCardId.engagementsReactionsOverTime: + return l10n.chartEngagementsReactionsOverTime; + case ChartCardId.engagementsCommentsOverTime: + return l10n.chartEngagementsCommentsOverTime; + case ChartCardId.engagementsReactionsByType: + return l10n.chartEngagementsReactionsByType; + case ChartCardId.engagementsReportsSubmittedOverTime: + return l10n.chartEngagementsReportsSubmittedOverTime; + case ChartCardId.engagementsReportsResolutionTimeOverTime: + return l10n.chartEngagementsReportsResolutionTimeOverTime; + case ChartCardId.engagementsReportsByReason: + return l10n.chartEngagementsReportsByReason; + case ChartCardId.engagementsAppReviewsFeedbackOverTime: + return l10n.chartEngagementsAppReviewsFeedbackOverTime; + case ChartCardId.engagementsAppReviewsPositiveVsNegative: + return l10n.chartEngagementsAppReviewsPositiveVsNegative; + case ChartCardId.engagementsAppReviewsStoreRequestsOverTime: + return l10n.chartEngagementsAppReviewsStoreRequestsOverTime; + } + } + String _timeFrameToLabel(ChartTimeFrame frame, AppLocalizations l10n) { switch (frame) { case ChartTimeFrame.week: From dcfaa7024a46c1e70935d50f3a222bb6e742d13c Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 08:58:54 +0100 Subject: [PATCH 45/59] refactor(analytics): improve KPI card layout and localization - Center main content and trend indicator - Add spacing between main value and trend indicator - Implement localized titles for different KPI cards - Update trend indicator color and tooltip message --- lib/shared/widgets/analytics/kpi_card.dart | 105 ++++++++++++++++----- 1 file changed, 84 insertions(+), 21 deletions(-) diff --git a/lib/shared/widgets/analytics/kpi_card.dart b/lib/shared/widgets/analytics/kpi_card.dart index 11eb8a58..c68c5201 100644 --- a/lib/shared/widgets/analytics/kpi_card.dart +++ b/lib/shared/widgets/analytics/kpi_card.dart @@ -45,7 +45,7 @@ class _KpiCardState extends State { final currentData = widget.data.timeFrames[_selectedTimeFrame]; return AnalyticsCardShell( - title: widget.data.label, + title: _getLocalizedTitle(widget.data.id, l10n), currentSlot: widget.slotIndex, totalSlots: widget.totalSlots, onSlotChanged: widget.onSlotChanged, @@ -55,27 +55,77 @@ class _KpiCardState extends State { timeFrameToString: (frame) => _timeFrameToLabel(frame, l10n), child: currentData == null ? Center(child: Text(l10n.noDataAvailable)) - : Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - currentData.value.toString(), - style: theme.textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, - color: theme.colorScheme.primary, + : Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + currentData.value.toString(), + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), ), - ), - const SizedBox(height: AppSpacing.xs), - _TrendIndicator( - trend: currentData.trend, - l10n: l10n, - ), - ], + const SizedBox(width: AppSpacing.sm), + _TrendIndicator( + trend: currentData.trend, + timeFrame: _selectedTimeFrame, + l10n: l10n, + ), + ], + ), ), ); } + String _getLocalizedTitle(KpiCardId id, AppLocalizations l10n) { + switch (id) { + case KpiCardId.usersTotalRegistered: + return l10n.kpiUsersTotalRegistered; + case KpiCardId.usersNewRegistrations: + return l10n.kpiUsersNewRegistrations; + case KpiCardId.usersActiveUsers: + return l10n.kpiUsersActiveUsers; + case KpiCardId.contentHeadlinesTotalPublished: + return l10n.kpiContentHeadlinesTotalPublished; + case KpiCardId.contentHeadlinesTotalViews: + return l10n.kpiContentHeadlinesTotalViews; + case KpiCardId.contentHeadlinesTotalLikes: + return l10n.kpiContentHeadlinesTotalLikes; + case KpiCardId.contentSourcesTotalSources: + return l10n.kpiContentSourcesTotalSources; + case KpiCardId.contentSourcesNewSources: + return l10n.kpiContentSourcesNewSources; + case KpiCardId.contentSourcesTotalFollowers: + return l10n.kpiContentSourcesTotalFollowers; + case KpiCardId.contentTopicsTotalTopics: + return l10n.kpiContentTopicsTotalTopics; + case KpiCardId.contentTopicsNewTopics: + return l10n.kpiContentTopicsNewTopics; + case KpiCardId.contentTopicsTotalFollowers: + return l10n.kpiContentTopicsTotalFollowers; + case KpiCardId.engagementsTotalReactions: + return l10n.kpiEngagementsTotalReactions; + case KpiCardId.engagementsTotalComments: + return l10n.kpiEngagementsTotalComments; + case KpiCardId.engagementsAverageEngagementRate: + return l10n.kpiEngagementsAverageEngagementRate; + case KpiCardId.engagementsReportsPending: + return l10n.kpiEngagementsReportsPending; + case KpiCardId.engagementsReportsResolved: + return l10n.kpiEngagementsReportsResolved; + case KpiCardId.engagementsReportsAverageResolutionTime: + return l10n.kpiEngagementsReportsAverageResolutionTime; + case KpiCardId.engagementsAppReviewsTotalFeedback: + return l10n.kpiEngagementsAppReviewsTotalFeedback; + case KpiCardId.engagementsAppReviewsPositiveFeedback: + return l10n.kpiEngagementsAppReviewsPositiveFeedback; + case KpiCardId.engagementsAppReviewsStoreRequests: + return l10n.kpiEngagementsAppReviewsStoreRequests; + } + } + String _timeFrameToLabel(KpiTimeFrame frame, AppLocalizations l10n) { switch (frame) { case KpiTimeFrame.day: @@ -93,23 +143,23 @@ class _KpiCardState extends State { class _TrendIndicator extends StatelessWidget { const _TrendIndicator({ required this.trend, + required this.timeFrame, required this.l10n, }); final String trend; + final KpiTimeFrame timeFrame; final AppLocalizations l10n; @override Widget build(BuildContext context) { final theme = Theme.of(context); final isPositive = !trend.startsWith('-'); - final color = isPositive - ? theme.colorScheme.primary - : theme.colorScheme.error; + final color = isPositive ? Colors.green : theme.colorScheme.error; final icon = isPositive ? Icons.arrow_upward : Icons.arrow_downward; return Tooltip( - message: l10n.vsPreviousPeriod, + message: _getTooltipMessage(timeFrame, l10n), child: Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( @@ -133,4 +183,17 @@ class _TrendIndicator extends StatelessWidget { ), ); } + + String _getTooltipMessage(KpiTimeFrame frame, AppLocalizations l10n) { + switch (frame) { + case KpiTimeFrame.day: + return l10n.vsPreviousDay; + case KpiTimeFrame.week: + return l10n.vsPreviousWeek; + case KpiTimeFrame.month: + return l10n.vsPreviousMonth; + case KpiTimeFrame.year: + return l10n.vsPreviousYear; + } + } } From 10a0f65fb6663c3900cab3d427f3efce06c6f9d7 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 08:59:07 +0100 Subject: [PATCH 46/59] refactor(analytics): improve ranked list card localization and layout - Add localized titles for different ranked list card types - Move time frame selector to bottom position - Update title display to use the new localized titles --- .../widgets/analytics/ranked_list_card.dart | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/shared/widgets/analytics/ranked_list_card.dart b/lib/shared/widgets/analytics/ranked_list_card.dart index 032e9ba7..bcb77d90 100644 --- a/lib/shared/widgets/analytics/ranked_list_card.dart +++ b/lib/shared/widgets/analytics/ranked_list_card.dart @@ -37,7 +37,7 @@ class _RankedListCardState extends State { final currentList = widget.data.timeFrames[_selectedTimeFrame]; return AnalyticsCardShell( - title: widget.data.label, + title: _getLocalizedTitle(widget.data.id, l10n), currentSlot: widget.slotIndex, totalSlots: widget.totalSlots, onSlotChanged: widget.onSlotChanged, @@ -45,6 +45,7 @@ class _RankedListCardState extends State { selectedTimeFrame: _selectedTimeFrame, onTimeFrameChanged: (value) => setState(() => _selectedTimeFrame = value), timeFrameToString: (frame) => _timeFrameToLabel(frame, l10n), + timeFramePosition: TimeFramePosition.bottom, child: (currentList == null || currentList.isEmpty) ? Center(child: Text(l10n.noDataAvailable)) : ListView.separated( @@ -87,6 +88,19 @@ class _RankedListCardState extends State { ); } + String _getLocalizedTitle(RankedListCardId id, AppLocalizations l10n) { + switch (id) { + case RankedListCardId.overviewHeadlinesMostViewed: + return l10n.rankedListOverviewHeadlinesMostViewed; + case RankedListCardId.overviewHeadlinesMostLiked: + return l10n.rankedListOverviewHeadlinesMostLiked; + case RankedListCardId.overviewSourcesMostFollowed: + return l10n.rankedListOverviewSourcesMostFollowed; + case RankedListCardId.overviewTopicsMostFollowed: + return l10n.rankedListOverviewTopicsMostFollowed; + } + } + String _timeFrameToLabel(RankedListTimeFrame frame, AppLocalizations l10n) { switch (frame) { case RankedListTimeFrame.day: From 4c1c812f78bf774d438adf5d4cd61587bf9cb68f Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 08:59:23 +0100 Subject: [PATCH 47/59] feat(analytics): add horizontal time frame selector position - Introduce TimeFramePosition enum to define selector position - Update AnalyticsCardShell to support both right and bottom positions - Implement _HorizontalTimeFrameSelector widget for bottom placement - Adjust layout to accommodate both selector positions --- .../analytics/analytics_card_shell.dart | 150 +++++++++++++----- 1 file changed, 114 insertions(+), 36 deletions(-) diff --git a/lib/shared/widgets/analytics/analytics_card_shell.dart b/lib/shared/widgets/analytics/analytics_card_shell.dart index eb23eb3e..e725d58b 100644 --- a/lib/shared/widgets/analytics/analytics_card_shell.dart +++ b/lib/shared/widgets/analytics/analytics_card_shell.dart @@ -1,13 +1,23 @@ import 'package:flutter/material.dart'; import 'package:ui_kit/ui_kit.dart'; +/// Defines the position of the time frame selector in the card. +enum TimeFramePosition { + /// Vertical list on the right edge. + right, + + /// Horizontal list at the bottom. + bottom, +} + /// {@template analytics_card_shell} /// A consistent container for all analytics cards (KPI, Chart, Ranked List). /// /// Implements the "Balanced Vertical Edges" design pattern: /// - **Left Edge:** Vertical slot navigation (dots) to switch between cards. /// - **Center:** The main content (Header + Body). -/// - **Right Edge:** Vertical time frame selector (textual). +/// - **Right/Bottom Edge:** Time frame selector (textual), positioned based on +/// [timeFramePosition]. /// /// This layout ensures controls are always accessible (even in "No Data" states) /// and maximizes vertical space for the content. @@ -24,6 +34,7 @@ class AnalyticsCardShell extends StatelessWidget { required this.selectedTimeFrame, required this.onTimeFrameChanged, required this.timeFrameToString, + this.timeFramePosition = TimeFramePosition.right, super.key, }); @@ -54,6 +65,9 @@ class AnalyticsCardShell extends StatelessWidget { /// Function to convert the time frame enum to a display string (e.g., "7D"). final String Function(T) timeFrameToString; + /// Determines where the time frame selector is placed. + final TimeFramePosition timeFramePosition; + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -70,49 +84,69 @@ class AnalyticsCardShell extends StatelessWidget { vertical: AppSpacing.md, horizontal: AppSpacing.sm, ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, + child: Column( children: [ - // --- Left Edge: Slot Navigation --- - if (totalSlots > 1) - _VerticalSlotIndicator( - currentSlot: currentSlot, - totalSlots: totalSlots, - onSlotChanged: onSlotChanged, - ), - if (totalSlots > 1) const SizedBox(width: AppSpacing.sm), - - // --- Center: Content --- Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Header - Text( - title, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, - color: theme.colorScheme.onSurfaceVariant, + // --- Left Edge: Slot Navigation --- + if (totalSlots > 1) + _VerticalSlotIndicator( + currentSlot: currentSlot, + totalSlots: totalSlots, + onSlotChanged: onSlotChanged, + ), + if (totalSlots > 1) const SizedBox(width: AppSpacing.sm), + + // --- Center: Content --- + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: AppSpacing.sm), + // Body + Expanded(child: child), + ], ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), - const SizedBox(height: AppSpacing.sm), - // Body - Expanded(child: child), + + // --- Right Edge: Time Frame Selector (Conditional) --- + if (timeFramePosition == TimeFramePosition.right) ...[ + const SizedBox(width: AppSpacing.sm), + Center( + child: _VerticalTimeFrameSelector( + timeFrames: timeFrames, + selectedTimeFrame: selectedTimeFrame, + onChanged: onTimeFrameChanged, + labelBuilder: timeFrameToString, + ), + ), + ], ], ), ), - const SizedBox(width: AppSpacing.sm), - - // --- Right Edge: Time Frame Selector --- - _VerticalTimeFrameSelector( - timeFrames: timeFrames, - selectedTimeFrame: selectedTimeFrame, - onChanged: onTimeFrameChanged, - labelBuilder: timeFrameToString, - ), + // --- Bottom Edge: Time Frame Selector (Conditional) --- + if (timeFramePosition == TimeFramePosition.bottom) ...[ + const SizedBox(height: AppSpacing.sm), + _HorizontalTimeFrameSelector( + timeFrames: timeFrames, + selectedTimeFrame: selectedTimeFrame, + onChanged: onTimeFrameChanged, + labelBuilder: timeFrameToString, + ), + ], ], ), ), @@ -175,7 +209,7 @@ class _VerticalTimeFrameSelector extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); return Column( - mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: timeFrames.map((frame) { final isSelected = frame == selectedTimeFrame; return InkWell( @@ -201,3 +235,47 @@ class _VerticalTimeFrameSelector extends StatelessWidget { ); } } + +class _HorizontalTimeFrameSelector extends StatelessWidget { + const _HorizontalTimeFrameSelector({ + required this.timeFrames, + required this.selectedTimeFrame, + required this.onChanged, + required this.labelBuilder, + }); + + final List timeFrames; + final T selectedTimeFrame; + final ValueChanged onChanged; + final String Function(T) labelBuilder; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: timeFrames.map((frame) { + final isSelected = frame == selectedTimeFrame; + return InkWell( + onTap: () => onChanged(frame), + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: Text( + labelBuilder(frame), + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ); + }).toList(), + ); + } +} From ce8a4dd2b14cf74a90389006b50fc4565c2ef7ba Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 08:59:52 +0100 Subject: [PATCH 48/59] refactor(bootstrap): temporarily replace fixture data with sample chart card data - Remove getChartCardsFixturesData() call - Add sample data for usersRegistrationsOverTime and contentHeadlinesViewsOverTime - Implement fallback for other ChartCardId values to prevent lookup errors - Update initialData assignment with new sample data structure --- lib/bootstrap.dart | 50 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 6bbd89bd..0665d246 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -155,7 +155,55 @@ Future bootstrap( chartCardsClient = DataInMemory( toJson: (i) => i.toJson(), getId: (i) => i.id.name, - initialData: getChartCardsFixturesData(), + initialData: [ + const ChartCardData( + id: ChartCardId.usersRegistrationsOverTime, + label: 'New Registrations', + type: ChartType.bar, + timeFrames: { + ChartTimeFrame.week: [ + DataPoint(label: 'Mon', value: 120), + DataPoint(label: 'Tue', value: 150), + DataPoint(label: 'Wed', value: 180), + DataPoint(label: 'Thu', value: 140), + DataPoint(label: 'Fri', value: 200), + DataPoint(label: 'Sat', value: 250), + DataPoint(label: 'Sun', value: 220), + ], + }, + ), + const ChartCardData( + id: ChartCardId.contentHeadlinesViewsOverTime, + label: 'Views Over Time', + type: ChartType.line, + timeFrames: { + ChartTimeFrame.week: [ + DataPoint(label: 'Mon', value: 5000), + DataPoint(label: 'Tue', value: 6200), + DataPoint(label: 'Wed', value: 5800), + DataPoint(label: 'Thu', value: 7000), + DataPoint(label: 'Fri', value: 8500), + DataPoint(label: 'Sat', value: 9000), + DataPoint(label: 'Sun', value: 8800), + ], + }, + ), + // Add a minimal placeholder for other charts to prevent lookup errors + ...ChartCardId.values + .where( + (id) => + id != ChartCardId.usersRegistrationsOverTime && + id != ChartCardId.contentHeadlinesViewsOverTime, + ) + .map( + (id) => ChartCardData( + id: id, + label: 'Data', + type: ChartType.line, + timeFrames: const {}, + ), + ), + ], logger: Logger('DataInMemory'), ); rankedListCardsClient = DataInMemory( From f13181d8f605d5be1c20212274387df2f2aca845 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 10:06:36 +0100 Subject: [PATCH 49/59] feat(bootstrap): add app constants validation - Import AppConstants from shared/constants/app_constants.dart - Add AppConstants.validate() call to ensure layout integrity before app initialization --- lib/bootstrap.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 0665d246..943d8eff 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -13,6 +13,7 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/app/app.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/app/config/config.dart' as app_config; import 'package:flutter_news_app_web_dashboard_full_source_code/bloc_observer.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/constants/app_constants.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/services/analytics_service.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/services/pending_deletions_service.dart'; import 'package:http_client/http_client.dart'; @@ -28,6 +29,9 @@ Future bootstrap( WidgetsFlutterBinding.ensureInitialized(); Bloc.observer = const AppBlocObserver(); + // Validate application constants to ensure layout integrity. + AppConstants.validate(); + timeago.setLocaleMessages('en', EnTimeagoMessages()); timeago.setLocaleMessages('ar', ArTimeagoMessages()); From 3196adb4a24978c54670d5d6a2686d06f92f2392 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 10:18:43 +0100 Subject: [PATCH 50/59] refactor(analytics): enhance responsiveness of dashboard strip - Add wide-screen support with a Row layout for KPI and Chart slots - Implement a Column layout for narrow screens, prioritizing KPI slot - Introduce fixed width for KPI slot on wide screens - Enhance documentation to reflect new responsive behavior --- .../analytics/analytics_dashboard_strip.dart | 72 +++++++++++++------ 1 file changed, 49 insertions(+), 23 deletions(-) diff --git a/lib/shared/widgets/analytics/analytics_dashboard_strip.dart b/lib/shared/widgets/analytics/analytics_dashboard_strip.dart index a7bab073..466fe8c8 100644 --- a/lib/shared/widgets/analytics/analytics_dashboard_strip.dart +++ b/lib/shared/widgets/analytics/analytics_dashboard_strip.dart @@ -1,5 +1,6 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/constants/app_constants.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/analytics/analytics_card_slot.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -7,12 +8,15 @@ import 'package:ui_kit/ui_kit.dart'; /// A reusable widget that displays the standard "Dashboard Strip" configuration /// for management pages. /// -/// It consists of two side-by-side [AnalyticsCardSlot]s: -/// - **Left Slot:** Displays a list of KPI cards. -/// - **Right Slot:** Displays a list of Chart cards. +/// It consists of two slots: +/// - **Left/Top Slot:** Displays a list of KPI cards. +/// - **Right/Bottom Slot:** Displays a list of Chart cards. /// -/// This ensures a consistent layout and behavior across Users, Content, and -/// Community management pages. +/// **Responsiveness:** +/// - On wide screens (>= [AppConstants.kDesktopBreakpoint]), it displays as a Row. +/// The KPI slot has a fixed width ([AppConstants.kAnalyticsKpiSidebarWidth]), +/// and the Chart slot takes the remaining space. +/// - On narrow screens, it displays as a Column, with both slots taking full width. /// {@endtemplate} class AnalyticsDashboardStrip extends StatelessWidget { /// {@macro analytics_dashboard_strip} @@ -22,10 +26,10 @@ class AnalyticsDashboardStrip extends StatelessWidget { super.key, }); - /// The list of KPI card IDs to display in the left slot. + /// The list of KPI card IDs to display in the left/top slot. final List kpiCards; - /// The list of Chart card IDs to display in the right slot. + /// The list of Chart card IDs to display in the right/bottom slot. final List chartCards; @override @@ -36,23 +40,45 @@ class AnalyticsDashboardStrip extends StatelessWidget { right: AppSpacing.sm, bottom: AppSpacing.md, ), - child: SizedBox( - height: 200, - child: Row( - children: [ - Expanded( - child: AnalyticsCardSlot( - cardIds: kpiCards, - ), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: AnalyticsCardSlot( - cardIds: chartCards, + child: LayoutBuilder( + builder: (context, constraints) { + final isWide = + constraints.maxWidth >= AppConstants.kDesktopBreakpoint; + + // Define the slots + final kpiSlot = AnalyticsCardSlot( + cardIds: kpiCards, + ); + + final chartSlot = AnalyticsCardSlot( + cardIds: chartCards, + ); + + if (isWide) { + // Desktop/Tablet: Row with fixed KPI width + return SizedBox( + height: 200, + child: Row( + children: [ + SizedBox( + width: AppConstants.kAnalyticsKpiSidebarWidth, + child: kpiSlot, + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: chartSlot, + ), + ], ), - ), - ], - ), + ); + } else { + // Mobile: Only show the chart slot to save vertical space for the table. + return SizedBox( + height: 150, + child: kpiSlot, + ); + } + }, ), ); } From a34cc1fbce8be51065386bd5fb003d4a7752fcad Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 10:18:58 +0100 Subject: [PATCH 51/59] feat(const): add new layout and UI constants - Add kDesktopBreakpoint and kAnalyticsKpiSidebarWidth constants - Implement validate() method to ensure UI configuration consistency - Update existing constant comments for clarity --- lib/shared/constants/app_constants.dart | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lib/shared/constants/app_constants.dart b/lib/shared/constants/app_constants.dart index 9fa85f3a..5888bfff 100644 --- a/lib/shared/constants/app_constants.dart +++ b/lib/shared/constants/app_constants.dart @@ -3,6 +3,15 @@ abstract final class AppConstants { /// The maximum width the application should occupy on large screens. static const double kMaxAppWidth = 1000; + /// The breakpoint width to switch between mobile (column) and desktop (row) layouts. + /// + /// Set to 600 to ensure the row layout triggers even when the navigation rail + /// consumes space within the kMaxAppWidth constraint. + static const double kDesktopBreakpoint = 600; + + /// The fixed width for the KPI sidebar in the analytics dashboard strip on large screens. + static const double kAnalyticsKpiSidebarWidth = 300; + /// The maximum width for the authentication pages. static const double kMaxAuthWidth = 400; @@ -27,6 +36,18 @@ abstract final class AppConstants { /// The duration for which a snackbar message is displayed, /// also used as the undo duration for pending deletions. static const Duration kSnackbarDuration = Duration(seconds: 5); + + /// Validates the configuration constants to ensure UI consistency. + /// + /// Throws an [AssertionError] if the configuration is invalid. + static void validate() { + assert( + kDesktopBreakpoint < kMaxAppWidth, + 'The desktop breakpoint ($kDesktopBreakpoint) must be less than the ' + 'maximum app width ($kMaxAppWidth) to ensure responsive layouts ' + 'function correctly.', + ); + } } /// A dummy [DateTime] used for placeholder models in UI. From 443986db620b13cadaf751720ba937e30031c81e Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 10:19:41 +0100 Subject: [PATCH 52/59] refactor: remove redundant SizedBox wrapper - Removed unnecessary SizedBox wrapper around AnalyticsDashboardStrip - Simplified the widget tree without changing functionality --- .../view/app_reviews_page.dart | 25 ++-- .../view/engagements_page.dart | 25 ++-- .../view/reports_page.dart | 25 ++-- .../view/headlines_page.dart | 25 ++-- lib/content_management/view/sources_page.dart | 25 ++-- lib/content_management/view/topics_page.dart | 25 ++-- lib/overview/view/overview_page.dart | 108 ++---------------- lib/user_management/view/users_page.dart | 25 ++-- 8 files changed, 89 insertions(+), 194 deletions(-) diff --git a/lib/community_management/view/app_reviews_page.dart b/lib/community_management/view/app_reviews_page.dart index dff32d74..a3948d91 100644 --- a/lib/community_management/view/app_reviews_page.dart +++ b/lib/community_management/view/app_reviews_page.dart @@ -105,20 +105,17 @@ class _AppReviewsPageState extends State { return Column( children: [ // Analytics Dashboard Strip - const SizedBox( - height: 160, - child: AnalyticsDashboardStrip( - kpiCards: [ - KpiCardId.engagementsAppReviewsTotalFeedback, - KpiCardId.engagementsAppReviewsPositiveFeedback, - KpiCardId.engagementsAppReviewsStoreRequests, - ], - chartCards: [ - ChartCardId.engagementsAppReviewsFeedbackOverTime, - ChartCardId.engagementsAppReviewsPositiveVsNegative, - ChartCardId.engagementsAppReviewsStoreRequestsOverTime, - ], - ), + const AnalyticsDashboardStrip( + kpiCards: [ + KpiCardId.engagementsAppReviewsTotalFeedback, + KpiCardId.engagementsAppReviewsPositiveFeedback, + KpiCardId.engagementsAppReviewsStoreRequests, + ], + chartCards: [ + ChartCardId.engagementsAppReviewsFeedbackOverTime, + ChartCardId.engagementsAppReviewsPositiveVsNegative, + ChartCardId.engagementsAppReviewsStoreRequestsOverTime, + ], ), if (state.appReviewsStatus == CommunityManagementStatus.loading && state.appReviews.isNotEmpty) diff --git a/lib/community_management/view/engagements_page.dart b/lib/community_management/view/engagements_page.dart index 02bfeeaf..70a9559e 100644 --- a/lib/community_management/view/engagements_page.dart +++ b/lib/community_management/view/engagements_page.dart @@ -104,20 +104,17 @@ class _EngagementsPageState extends State { return Column( children: [ // Analytics Dashboard Strip - const SizedBox( - height: 160, - child: AnalyticsDashboardStrip( - kpiCards: [ - KpiCardId.engagementsTotalReactions, - KpiCardId.engagementsTotalComments, - KpiCardId.engagementsAverageEngagementRate, - ], - chartCards: [ - ChartCardId.engagementsReactionsOverTime, - ChartCardId.engagementsCommentsOverTime, - ChartCardId.engagementsReactionsByType, - ], - ), + const AnalyticsDashboardStrip( + kpiCards: [ + KpiCardId.engagementsTotalReactions, + KpiCardId.engagementsTotalComments, + KpiCardId.engagementsAverageEngagementRate, + ], + chartCards: [ + ChartCardId.engagementsReactionsOverTime, + ChartCardId.engagementsCommentsOverTime, + ChartCardId.engagementsReactionsByType, + ], ), if (state.engagementsStatus == CommunityManagementStatus.loading && diff --git a/lib/community_management/view/reports_page.dart b/lib/community_management/view/reports_page.dart index ca2572d3..5833832e 100644 --- a/lib/community_management/view/reports_page.dart +++ b/lib/community_management/view/reports_page.dart @@ -100,20 +100,17 @@ class _ReportsPageState extends State { return Column( children: [ // Analytics Dashboard Strip - const SizedBox( - height: 160, - child: AnalyticsDashboardStrip( - kpiCards: [ - KpiCardId.engagementsReportsPending, - KpiCardId.engagementsReportsResolved, - KpiCardId.engagementsReportsAverageResolutionTime, - ], - chartCards: [ - ChartCardId.engagementsReportsSubmittedOverTime, - ChartCardId.engagementsReportsResolutionTimeOverTime, - ChartCardId.engagementsReportsByReason, - ], - ), + const AnalyticsDashboardStrip( + kpiCards: [ + KpiCardId.engagementsReportsPending, + KpiCardId.engagementsReportsResolved, + KpiCardId.engagementsReportsAverageResolutionTime, + ], + chartCards: [ + ChartCardId.engagementsReportsSubmittedOverTime, + ChartCardId.engagementsReportsResolutionTimeOverTime, + ChartCardId.engagementsReportsByReason, + ], ), if (state.reportsStatus == CommunityManagementStatus.loading && state.reports.isNotEmpty) diff --git a/lib/content_management/view/headlines_page.dart b/lib/content_management/view/headlines_page.dart index 5d69e9d1..921e4a46 100644 --- a/lib/content_management/view/headlines_page.dart +++ b/lib/content_management/view/headlines_page.dart @@ -116,20 +116,17 @@ class _HeadlinesPageState extends State { return Column( children: [ // Analytics Dashboard Strip - const SizedBox( - height: 160, - child: AnalyticsDashboardStrip( - kpiCards: [ - KpiCardId.contentHeadlinesTotalPublished, - KpiCardId.contentHeadlinesTotalViews, - KpiCardId.contentHeadlinesTotalLikes, - ], - chartCards: [ - ChartCardId.contentHeadlinesViewsOverTime, - ChartCardId.contentHeadlinesLikesOverTime, - ChartCardId.contentHeadlinesViewsByTopic, - ], - ), + const AnalyticsDashboardStrip( + kpiCards: [ + KpiCardId.contentHeadlinesTotalPublished, + KpiCardId.contentHeadlinesTotalViews, + KpiCardId.contentHeadlinesTotalLikes, + ], + chartCards: [ + ChartCardId.contentHeadlinesViewsOverTime, + ChartCardId.contentHeadlinesLikesOverTime, + ChartCardId.contentHeadlinesViewsByTopic, + ], ), if (state.headlinesStatus == ContentManagementStatus.loading && state.headlines.isNotEmpty) diff --git a/lib/content_management/view/sources_page.dart b/lib/content_management/view/sources_page.dart index 4dc71e0d..28e6eded 100644 --- a/lib/content_management/view/sources_page.dart +++ b/lib/content_management/view/sources_page.dart @@ -115,20 +115,17 @@ class _SourcesPageState extends State { return Column( children: [ // Analytics Dashboard Strip - const SizedBox( - height: 160, - child: AnalyticsDashboardStrip( - kpiCards: [ - KpiCardId.contentSourcesTotalSources, - KpiCardId.contentSourcesNewSources, - KpiCardId.contentSourcesTotalFollowers, - ], - chartCards: [ - ChartCardId.contentSourcesHeadlinesPublishedOverTime, - ChartCardId.contentSourcesStatusDistribution, - ChartCardId.contentSourcesEngagementByType, - ], - ), + const AnalyticsDashboardStrip( + kpiCards: [ + KpiCardId.contentSourcesTotalSources, + KpiCardId.contentSourcesNewSources, + KpiCardId.contentSourcesTotalFollowers, + ], + chartCards: [ + ChartCardId.contentSourcesHeadlinesPublishedOverTime, + ChartCardId.contentSourcesStatusDistribution, + ChartCardId.contentSourcesEngagementByType, + ], ), if (state.sourcesStatus == ContentManagementStatus.loading && state.sources.isNotEmpty) diff --git a/lib/content_management/view/topics_page.dart b/lib/content_management/view/topics_page.dart index cd91208f..fe80d059 100644 --- a/lib/content_management/view/topics_page.dart +++ b/lib/content_management/view/topics_page.dart @@ -111,20 +111,17 @@ class _TopicPageState extends State { return Column( children: [ // Analytics Dashboard Strip - const SizedBox( - height: 160, - child: AnalyticsDashboardStrip( - kpiCards: [ - KpiCardId.contentTopicsTotalTopics, - KpiCardId.contentTopicsNewTopics, - KpiCardId.contentTopicsTotalFollowers, - ], - chartCards: [ - ChartCardId.contentHeadlinesBreakingNewsDistribution, - ChartCardId.contentTopicsHeadlinesPublishedOverTime, - ChartCardId.contentTopicsEngagementByTopic, - ], - ), + const AnalyticsDashboardStrip( + kpiCards: [ + KpiCardId.contentTopicsTotalTopics, + KpiCardId.contentTopicsNewTopics, + KpiCardId.contentTopicsTotalFollowers, + ], + chartCards: [ + ChartCardId.contentHeadlinesBreakingNewsDistribution, + ChartCardId.contentTopicsHeadlinesPublishedOverTime, + ChartCardId.contentTopicsEngagementByTopic, + ], ), if (state.topicsStatus == ContentManagementStatus.loading && state.topics.isNotEmpty) diff --git a/lib/overview/view/overview_page.dart b/lib/overview/view/overview_page.dart index 15e2c031..6cad3108 100644 --- a/lib/overview/view/overview_page.dart +++ b/lib/overview/view/overview_page.dart @@ -1,6 +1,7 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/constants/app_constants.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/analytics/analytics_card_slot.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -27,16 +28,15 @@ class OverviewPage extends StatelessWidget { child: LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; - // Breakpoints - final isDesktop = width > 1200; - final isTablet = width > 800 && width <= 1200; + // Use the configurable breakpoint from AppConstants. + final isWide = width >= AppConstants.kDesktopBreakpoint; // Define card heights const kpiHeight = 160.0; const chartHeight = 350.0; - if (isDesktop) { - // Desktop: 3 columns for KPIs, 2 columns for Charts/Lists + if (isWide) { + // Wide Layout (Desktop/Tablet): 3 columns for KPIs, 2 columns for Charts/Lists return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -71,102 +71,18 @@ class OverviewPage extends StatelessWidget { const SizedBox(height: AppSpacing.lg), SizedBox( height: chartHeight, - child: Row( - children: [ - Expanded( - child: AnalyticsCardSlot( - cardIds: const [ - ChartCardId.usersRegistrationsOverTime, - ], - ), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: AnalyticsCardSlot( - cardIds: const [ - ChartCardId.contentHeadlinesViewsOverTime, - ], - ), - ), - ], - ), - ), - const SizedBox(height: AppSpacing.lg), - SizedBox( - height: chartHeight, - child: Row( - children: [ - Expanded( - child: AnalyticsCardSlot( - cardIds: const [ - RankedListCardId.overviewHeadlinesMostViewed, - ], - ), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: AnalyticsCardSlot( - cardIds: const [ - RankedListCardId.overviewSourcesMostFollowed, - ], - ), - ), - ], - ), - ), - ], - ); - } else if (isTablet) { - // Tablet: 2 columns for everything - return Column( - children: [ - SizedBox( - height: kpiHeight, - child: Row( - children: [ - Expanded( - child: AnalyticsCardSlot( - cardIds: const [KpiCardId.usersTotalRegistered], - ), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: AnalyticsCardSlot( - cardIds: const [ - KpiCardId.contentHeadlinesTotalViews, - ], - ), - ), + child: AnalyticsCardSlot( + cardIds: const [ + ChartCardId.usersRegistrationsOverTime, ], ), ), const SizedBox(height: AppSpacing.md), - SizedBox( - height: kpiHeight, - child: AnalyticsCardSlot( - cardIds: const [KpiCardId.engagementsReportsPending], - ), - ), - const SizedBox(height: AppSpacing.lg), SizedBox( height: chartHeight, - child: Row( - children: [ - Expanded( - child: AnalyticsCardSlot( - cardIds: const [ - ChartCardId.usersRegistrationsOverTime, - ], - ), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: AnalyticsCardSlot( - cardIds: const [ - ChartCardId.contentHeadlinesViewsOverTime, - ], - ), - ), + child: AnalyticsCardSlot( + cardIds: const [ + ChartCardId.contentHeadlinesViewsOverTime, ], ), ), @@ -196,7 +112,7 @@ class OverviewPage extends StatelessWidget { ], ); } else { - // Mobile: 1 column + // Narrow Layout (Mobile): 1 column for everything return Column( children: [ SizedBox( diff --git a/lib/user_management/view/users_page.dart b/lib/user_management/view/users_page.dart index 3342b7ec..e5824bfd 100644 --- a/lib/user_management/view/users_page.dart +++ b/lib/user_management/view/users_page.dart @@ -123,20 +123,17 @@ class _UsersPageState extends State { return Column( children: [ // Analytics Dashboard Strip - const SizedBox( - height: 160, - child: AnalyticsDashboardStrip( - kpiCards: [ - KpiCardId.usersTotalRegistered, - KpiCardId.usersNewRegistrations, - KpiCardId.usersActiveUsers, - ], - chartCards: [ - ChartCardId.usersRegistrationsOverTime, - ChartCardId.usersActiveUsersOverTime, - ChartCardId.usersRoleDistribution, - ], - ), + const AnalyticsDashboardStrip( + kpiCards: [ + KpiCardId.usersTotalRegistered, + KpiCardId.usersNewRegistrations, + KpiCardId.usersActiveUsers, + ], + chartCards: [ + ChartCardId.usersRegistrationsOverTime, + ChartCardId.usersActiveUsersOverTime, + ChartCardId.usersRoleDistribution, + ], ), // Show a linear progress indicator during subsequent loads/pagination. if (state.status == UserManagementStatus.loading && From 349348ba3106ab45ac8517006498d9d493f62823 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 11:09:28 +0100 Subject: [PATCH 53/59] style(analytics): adjust padding for chart card content - Add left and right padding to the chart card content - Improve visual consistency and balance within --- lib/shared/widgets/analytics/chart_card.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/shared/widgets/analytics/chart_card.dart b/lib/shared/widgets/analytics/chart_card.dart index 95d8d4cc..908cf0a7 100644 --- a/lib/shared/widgets/analytics/chart_card.dart +++ b/lib/shared/widgets/analytics/chart_card.dart @@ -52,6 +52,8 @@ class _ChartCardState extends State { padding: const EdgeInsets.only( top: AppSpacing.sm, bottom: AppSpacing.xs, + left: AppSpacing.sm, + right: AppSpacing.sm, ), child: widget.data.type == ChartType.line ? _LineChart( From 77682f308b22325eebfa747fa1ea0a8ce188a763 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 11:35:47 +0100 Subject: [PATCH 54/59] fix(analytics): enable RTL support for chart cards - Add RTL support for line and bar charts - Invert x values for RTL mode - Sort bar groups by x value to render correctly in RTL - Update bottom title positioning for RTL --- lib/shared/widgets/analytics/chart_card.dart | 24 ++++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/lib/shared/widgets/analytics/chart_card.dart b/lib/shared/widgets/analytics/chart_card.dart index 908cf0a7..c4116878 100644 --- a/lib/shared/widgets/analytics/chart_card.dart +++ b/lib/shared/widgets/analytics/chart_card.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/analytics/analytics_card_shell.dart'; -import 'package:intl/intl.dart'; +import 'package:intl/intl.dart' hide TextDirection; import 'package:ui_kit/ui_kit.dart'; /// {@template chart_card} @@ -137,9 +137,13 @@ class _LineChart extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final primaryColor = theme.colorScheme.primary; + final isRtl = Directionality.of(context) == TextDirection.rtl; final spots = points.asMap().entries.map((entry) { - return FlSpot(entry.key.toDouble(), entry.value.value.toDouble()); + final x = isRtl + ? (points.length - 1 - entry.key).toDouble() + : entry.key.toDouble(); + return FlSpot(x, entry.value.value.toDouble()); }).toList(); final maxY = points.map((e) => e.value).reduce((a, b) => a > b ? a : b); @@ -156,6 +160,7 @@ class _LineChart extends StatelessWidget { meta: meta, points: points, timeFrame: timeFrame, + isRtl: isRtl, ), reservedSize: 20, ), @@ -202,10 +207,12 @@ class _BarChart extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final primaryColor = theme.colorScheme.primary; + final isRtl = Directionality.of(context) == TextDirection.rtl; final barGroups = points.asMap().entries.map((entry) { + final x = isRtl ? points.length - 1 - entry.key : entry.key; return BarChartGroupData( - x: entry.key, + x: x, barRods: [ BarChartRodData( toY: entry.value.value.toDouble(), @@ -215,7 +222,10 @@ class _BarChart extends StatelessWidget { ), ], ); - }).toList(); + }).toList() + + // Ensure groups are sorted by X to render correctly in RTL + ..sort((a, b) => a.x.compareTo(b.x)); final maxY = points.map((e) => e.value).reduce((a, b) => a > b ? a : b); @@ -231,6 +241,7 @@ class _BarChart extends StatelessWidget { meta: meta, points: points, timeFrame: timeFrame, + isRtl: isRtl, ), reservedSize: 20, ), @@ -260,16 +271,19 @@ class _BottomTitle extends StatelessWidget { required this.points, required this.timeFrame, required this.meta, + required this.isRtl, }); final double value; final List points; final ChartTimeFrame timeFrame; final TitleMeta meta; + final bool isRtl; @override Widget build(BuildContext context) { - final index = value.toInt(); + final index = isRtl ? (points.length - 1) - value.toInt() : value.toInt(); + if (index < 0 || index >= points.length) return const SizedBox.shrink(); final point = points[index]; From b2d3b7778ff7331f40fb24eb7c94256484ffa238 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 13:15:30 +0100 Subject: [PATCH 55/59] refactor(bootstrap): use chart cards fixtures --- lib/bootstrap.dart | 50 +--------------------------------------------- 1 file changed, 1 insertion(+), 49 deletions(-) diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 943d8eff..05ac13b7 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -159,55 +159,7 @@ Future bootstrap( chartCardsClient = DataInMemory( toJson: (i) => i.toJson(), getId: (i) => i.id.name, - initialData: [ - const ChartCardData( - id: ChartCardId.usersRegistrationsOverTime, - label: 'New Registrations', - type: ChartType.bar, - timeFrames: { - ChartTimeFrame.week: [ - DataPoint(label: 'Mon', value: 120), - DataPoint(label: 'Tue', value: 150), - DataPoint(label: 'Wed', value: 180), - DataPoint(label: 'Thu', value: 140), - DataPoint(label: 'Fri', value: 200), - DataPoint(label: 'Sat', value: 250), - DataPoint(label: 'Sun', value: 220), - ], - }, - ), - const ChartCardData( - id: ChartCardId.contentHeadlinesViewsOverTime, - label: 'Views Over Time', - type: ChartType.line, - timeFrames: { - ChartTimeFrame.week: [ - DataPoint(label: 'Mon', value: 5000), - DataPoint(label: 'Tue', value: 6200), - DataPoint(label: 'Wed', value: 5800), - DataPoint(label: 'Thu', value: 7000), - DataPoint(label: 'Fri', value: 8500), - DataPoint(label: 'Sat', value: 9000), - DataPoint(label: 'Sun', value: 8800), - ], - }, - ), - // Add a minimal placeholder for other charts to prevent lookup errors - ...ChartCardId.values - .where( - (id) => - id != ChartCardId.usersRegistrationsOverTime && - id != ChartCardId.contentHeadlinesViewsOverTime, - ) - .map( - (id) => ChartCardData( - id: id, - label: 'Data', - type: ChartType.line, - timeFrames: const {}, - ), - ), - ], + initialData: getChartCardsFixturesData(), logger: Logger('DataInMemory'), ); rankedListCardsClient = DataInMemory( From ebeef7c340aadf88fa11f6067a34243fcb9ffcf8 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 13:15:44 +0100 Subject: [PATCH 56/59] style: format --- lib/shared/services/analytics_service.dart | 9 +++-- lib/shared/widgets/analytics/chart_card.dart | 36 +++++++++++--------- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/lib/shared/services/analytics_service.dart b/lib/shared/services/analytics_service.dart index c20a2a10..7fa0eb9d 100644 --- a/lib/shared/services/analytics_service.dart +++ b/lib/shared/services/analytics_service.dart @@ -19,9 +19,9 @@ class AnalyticsService { required DataRepository kpiRepository, required DataRepository chartRepository, required DataRepository rankedListRepository, - }) : _kpiRepository = kpiRepository, - _chartRepository = chartRepository, - _rankedListRepository = rankedListRepository; + }) : _kpiRepository = kpiRepository, + _chartRepository = chartRepository, + _rankedListRepository = rankedListRepository; final DataRepository _kpiRepository; final DataRepository _chartRepository; @@ -35,8 +35,7 @@ class AnalyticsService { // --- In-Flight Requests (Deduplication) --- final _kpiInFlight = >{}; final _chartInFlight = >{}; - final _rankedListInFlight = - >{}; + final _rankedListInFlight = >{}; /// Fetches a KPI card by its [id]. /// diff --git a/lib/shared/widgets/analytics/chart_card.dart b/lib/shared/widgets/analytics/chart_card.dart index c4116878..1724e7c4 100644 --- a/lib/shared/widgets/analytics/chart_card.dart +++ b/lib/shared/widgets/analytics/chart_card.dart @@ -209,23 +209,25 @@ class _BarChart extends StatelessWidget { final primaryColor = theme.colorScheme.primary; final isRtl = Directionality.of(context) == TextDirection.rtl; - final barGroups = points.asMap().entries.map((entry) { - final x = isRtl ? points.length - 1 - entry.key : entry.key; - return BarChartGroupData( - x: x, - barRods: [ - BarChartRodData( - toY: entry.value.value.toDouble(), - color: primaryColor, - width: 12, - borderRadius: const BorderRadius.vertical(top: Radius.circular(4)), - ), - ], - ); - }).toList() - - // Ensure groups are sorted by X to render correctly in RTL - ..sort((a, b) => a.x.compareTo(b.x)); + final barGroups = + points.asMap().entries.map((entry) { + final x = isRtl ? points.length - 1 - entry.key : entry.key; + return BarChartGroupData( + x: x, + barRods: [ + BarChartRodData( + toY: entry.value.value.toDouble(), + color: primaryColor, + width: 12, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(4), + ), + ), + ], + ); + }).toList() + // Ensure groups are sorted by X to render correctly in RTL + ..sort((a, b) => a.x.compareTo(b.x)); final maxY = points.map((e) => e.value).reduce((a, b) => a > b ? a : b); From db526a2824e39f07ba8cd7383a066a333a44bc05 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 13:41:42 +0100 Subject: [PATCH 57/59] build(deps): update core dependency - Update core dependency to version 1.4.0 - Change git ref from ff02760 to c075c3da --- pubspec.lock | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 840de880..4532247e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -89,8 +89,8 @@ packages: dependency: "direct main" description: path: "." - ref: ff027602d9b33447b3595855f7a548e308d41945 - resolved-ref: ff027602d9b33447b3595855f7a548e308d41945 + ref: c075c3daed1f4ff849dc0326b0343b2d5ba5a922 + resolved-ref: c075c3daed1f4ff849dc0326b0343b2d5ba5a922 url: "https://github.com/flutter-news-app-full-source-code/core.git" source: git version: "1.4.0" diff --git a/pubspec.yaml b/pubspec.yaml index a302ce23..b853436e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -95,7 +95,7 @@ dependency_overrides: core: git: url: https://github.com/flutter-news-app-full-source-code/core.git - ref: ff027602d9b33447b3595855f7a548e308d41945 + ref: c075c3daed1f4ff849dc0326b0343b2d5ba5a922 http_client: git: url: https://github.com/flutter-news-app-full-source-code/http-client.git From 899767684d2e84690d4929125e328e71b8ed3234 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 13:42:07 +0100 Subject: [PATCH 58/59] feat(analytics): add navigation on ranked list item tap - Implement navigation to edit screens when tapping items in ranked list card - Add GoRouter dependencies and import statements - Define navigation logic based on different ranked list card IDs - Update ListTile to include onTap gesture recognizer --- .../widgets/analytics/ranked_list_card.dart | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/lib/shared/widgets/analytics/ranked_list_card.dart b/lib/shared/widgets/analytics/ranked_list_card.dart index bcb77d90..d2e12d07 100644 --- a/lib/shared/widgets/analytics/ranked_list_card.dart +++ b/lib/shared/widgets/analytics/ranked_list_card.dart @@ -2,7 +2,9 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/router/routes.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/analytics/analytics_card_shell.dart'; +import 'package:go_router/go_router.dart'; /// {@template ranked_list_card} /// A widget that displays a ranked list of items (e.g., Top 5 Headlines) @@ -55,6 +57,7 @@ class _RankedListCardState extends State { itemBuilder: (context, index) { final item = currentList[index]; return ListTile( + onTap: () => _onItemTapped(context, widget.data.id, item), contentPadding: EdgeInsets.zero, dense: true, leading: CircleAvatar( @@ -88,6 +91,29 @@ class _RankedListCardState extends State { ); } + void _onItemTapped( + BuildContext context, + RankedListCardId cardId, + RankedListItem item, + ) { + final entityId = item.entityId; + switch (cardId) { + case RankedListCardId.overviewHeadlinesMostViewed: + case RankedListCardId.overviewHeadlinesMostLiked: + context.goNamed( + Routes.editHeadlineName, + pathParameters: {'id': entityId}, + ); + case RankedListCardId.overviewSourcesMostFollowed: + context.goNamed( + Routes.editSourceName, + pathParameters: {'id': entityId}, + ); + case RankedListCardId.overviewTopicsMostFollowed: + context.goNamed(Routes.editTopicName, pathParameters: {'id': entityId}); + } + } + String _getLocalizedTitle(RankedListCardId id, AppLocalizations l10n) { switch (id) { case RankedListCardId.overviewHeadlinesMostViewed: From a15e358ae6b4be94e04198d1e30e55431e9c501f Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Dec 2025 14:23:39 +0100 Subject: [PATCH 59/59] docs(README): update content and operational intelligence sections - Comment out code coverage badge - Add Operational Intelligence section with dashboard overview - Enhance Content & Editorial Management section with performance metrics - Update User & Role Management section with user growth insights - Improve Moderation Hub section with community health monitoring --- README.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9f12ed7d..adadd842 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

Live Demo: View Documentation: Read - +

Trial License: View Terms @@ -22,13 +22,25 @@ This dashboard provides a complete, production-ready command center for your ent Explore the high-level domains below to see how. +

+📊 Operational Intelligence + +### 📈 Dashboard Overview +A centralized command center providing a real-time pulse on your entire news operation. +- **Unified Business Intelligence:** View pre-aggregated metrics that combine user behavior data with operational stats for a holistic performance picture. +- **High-Performance Visualization:** Visualize growth and trends with interactive charts that load instantly, powered by an optimized ETL backend engine. +- **Top Content Ranking:** Instantly identify your highest-performing headlines, sources, and topics to double down on what works. +> **Your Advantage:** Move from reactive management to proactive strategy. The dashboard delivers fast, actionable insights without the latency of direct provider queries, helping you spot trends early and optimize your content strategy. + +
+
✍️ Content & Editorial Management ### 📰 Complete Editorial Control Manage the entire lifecycle of your content from a single, intuitive interface. This is more than just a database editor; it's a complete content operations hub. - **Full Content Lifecycle:** Seamlessly draft, publish, edit, archive, and restore all content assets, including headlines, topics, and news sources. -- **At-a-Glance Operational Overview:** A centralized dashboard provides a real-time snapshot of your content ecosystem, including key statistics and shortcuts for common editorial tasks. +- **Contextual Performance Metrics:** Make informed editorial decisions with real-time views, likes, and engagement data integrated directly into your content lists. > **Your Advantage:** Gain granular control over your entire content pipeline. This centralized system streamlines your editorial workflow, ensures content consistency, and simplifies asset management.
@@ -39,6 +51,7 @@ Manage the entire lifecycle of your content from a single, intuitive interface. ### 👥 Granular User & Role Management Effortlessly manage your entire user base with a dedicated user management system. View all registered users, filter them by email or role, and dynamically adjust their dashboard permissions. - **Full User Roster:** See a comprehensive list of all users, including their email, app subscription level, and current dashboard role. +- **User Growth Insights:** Track registration trends and active user metrics alongside your user roster to understand audience growth. - **Dynamic Role Promotion:** Promote trusted users to a "Publisher" role, granting them content management capabilities without full administrative access. - **Powerful Filtering:** Quickly locate specific users or user segments with multi-faceted filtering by email, app role, and dashboard role. > **Your Advantage:** Delegate content creation responsibilities securely, build out your editorial team, and maintain a clear overview of all system users and their permissions, all from a single, centralized interface. @@ -51,6 +64,7 @@ Effortlessly manage your entire user base with a dedicated user management syste ### 💬 Comprehensive Moderation Hub Directly manage all user-generated content from a centralized command center. Review, moderate, and act on user interactions to maintain a healthy and constructive community environment. - **Unified Content Review:** Seamlessly moderate all incoming user engagements (reactions and comments), content reports, and app review feedback from a single, intuitive interface. +- **Community Health Monitoring:** Visualize engagement rates and report resolution times to maintain a healthy community ecosystem. - **Streamlined Moderation Workflow:** Quickly approve or reject comments, resolve user-submitted reports, and analyze feedback with a consistent set of tools designed for rapid decision-making. - **Direct User Insight:** Gain a clear, unfiltered view of user sentiment, content issues, and overall satisfaction by directly engaging with their feedback and reports. > **Your Advantage:** Foster a positive community, protect your brand by quickly addressing problematic content, and gather valuable user insights to improve your content strategy, all from one integrated hub.