From 917f4c516850e132d3c8e218348bb9d4ea8dbfc9 Mon Sep 17 00:00:00 2001 From: MSOB7YY Date: Wed, 1 Nov 2023 02:33:48 +0200 Subject: [PATCH] feat: heatmap view for listens --- lib/controller/settings_controller.dart | 7 + lib/ui/dialogs/track_listens_dialog.dart | 181 ++++++++++++++---- .../functions/video_listens_dialog.dart | 81 ++------ 3 files changed, 172 insertions(+), 97 deletions(-) diff --git a/lib/controller/settings_controller.dart b/lib/controller/settings_controller.dart index 40d83274..aedec4d8 100644 --- a/lib/controller/settings_controller.dart +++ b/lib/controller/settings_controller.dart @@ -91,6 +91,7 @@ class SettingsController { final RxString defaultFolderStartupLocation = kStoragePaths.first.obs; final RxBool enableFoldersHierarchy = true.obs; final RxBool displayArtistBeforeTitle = true.obs; + final RxBool heatmapListensView = false.obs; final RxList backupItemslist = [ AppPaths.TRACKS, AppPaths.TRACKS_STATS, @@ -361,6 +362,7 @@ class SettingsController { defaultFolderStartupLocation.value = json['defaultFolderStartupLocation'] ?? defaultFolderStartupLocation.value; enableFoldersHierarchy.value = json['enableFoldersHierarchy'] ?? enableFoldersHierarchy.value; displayArtistBeforeTitle.value = json['displayArtistBeforeTitle'] ?? displayArtistBeforeTitle.value; + heatmapListensView.value = json['heatmapListensView'] ?? heatmapListensView.value; backupItemslist.value = List.from(json['backupItemslist'] ?? backupItemslist); enableVideoPlayback.value = json['enableVideoPlayback'] ?? enableVideoPlayback.value; enableLyrics.value = json['enableLyrics'] ?? enableLyrics.value; @@ -565,6 +567,7 @@ class SettingsController { 'defaultFolderStartupLocation': defaultFolderStartupLocation.value, 'enableFoldersHierarchy': enableFoldersHierarchy.value, 'displayArtistBeforeTitle': displayArtistBeforeTitle.value, + 'heatmapListensView': heatmapListensView.value, 'backupItemslist': backupItemslist.toList(), 'enableVideoPlayback': enableVideoPlayback.value, 'enableLyrics': enableLyrics.value, @@ -731,6 +734,7 @@ class SettingsController { String? defaultFolderStartupLocation, bool? enableFoldersHierarchy, bool? displayArtistBeforeTitle, + bool? heatmapListensView, List? backupItemslist, bool? enableVideoPlayback, bool? enableLyrics, @@ -1043,6 +1047,9 @@ class SettingsController { if (displayArtistBeforeTitle != null) { this.displayArtistBeforeTitle.value = displayArtistBeforeTitle; } + if (heatmapListensView != null) { + this.heatmapListensView.value = heatmapListensView; + } if (backupItemslist != null) { backupItemslist.loop((d, index) { if (!this.backupItemslist.contains(d)) { diff --git a/lib/ui/dialogs/track_listens_dialog.dart b/lib/ui/dialogs/track_listens_dialog.dart index 6d5ca466..c62e50c0 100644 --- a/lib/ui/dialogs/track_listens_dialog.dart +++ b/lib/ui/dialogs/track_listens_dialog.dart @@ -1,24 +1,64 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:paged_vertical_calendar/paged_vertical_calendar.dart'; import 'package:namida/class/track.dart'; import 'package:namida/controller/current_color.dart'; import 'package:namida/controller/history_controller.dart'; import 'package:namida/controller/navigator_controller.dart'; +import 'package:namida/controller/settings_controller.dart'; import 'package:namida/core/dimensions.dart'; import 'package:namida/core/extensions.dart'; import 'package:namida/core/functions.dart'; +import 'package:namida/core/icon_fonts/broken_icons.dart'; import 'package:namida/core/translations/language.dart'; import 'package:namida/ui/widgets/custom_widgets.dart'; -void showTrackListensDialog(Track track, {List? datesOfListen, Color? colorScheme}) async { - datesOfListen ??= HistoryController.inst.topTracksMapListens[track] ?? []; - final color = colorScheme ?? await CurrentColor.inst.getTrackDelightnedColor(track); +void showTrackListensDialog(Track track, {List datesOfListen = const [], Color? colorScheme}) async { + showListensDialog( + datesOfListen: datesOfListen.isNotEmpty ? datesOfListen : HistoryController.inst.topTracksMapListens[track] ?? [], + colorScheme: () async => colorScheme ?? await CurrentColor.inst.getTrackDelightnedColor(track), + onListenTap: (listen) { + final scrollInfo = HistoryController.inst.getListenScrollPosition( + listenMS: listen, + extraItemsOffset: 2, + ); + NamidaOnTaps.inst.onHistoryPlaylistTap( + indexToHighlight: scrollInfo.indexOfSmallList, + dayOfHighLight: scrollInfo.dayToHighLight, + initialScrollOffset: (scrollInfo.itemsToScroll * Dimensions.inst.trackTileItemExtent) + (scrollInfo.daysToScroll * kHistoryDayHeaderHeightWithPadding), + ); + }, + ); +} +void showListensDialog({ + required List datesOfListen, + required FutureOr Function() colorScheme, + required void Function(int listen) onListenTap, +}) async { if (datesOfListen.isEmpty) return; datesOfListen.sortByReverse((e) => e); + final color = await colorScheme() ?? CurrentColor.inst.color; + + final datesMapByDay = >{}; + final datesMapByMonth = >{}; + for (final d in datesOfListen) { + final date = DateTime.fromMillisecondsSinceEpoch(d); + final dayDate = DateTime(date.year, date.month, date.day); + final monthDate = DateTime(date.year, date.month); + datesMapByDay.addForce(dayDate, date); + datesMapByMonth.addForce(monthDate, date); + } + + final firstListen = DateTime.fromMillisecondsSinceEpoch(datesOfListen.last); + final lastListen = DateTime.fromMillisecondsSinceEpoch(datesOfListen.first); + NamidaNavigator.inst.navigateDialog( colorScheme: color, lighterDialogColor: false, @@ -28,43 +68,116 @@ void showTrackListensDialog(Track track, {List? datesOfListen, Color? color title: lang.TOTAL_LISTENS, trailingWidgets: [ Text( - '${datesOfListen!.length}', + '${datesOfListen.length}', style: Get.textTheme.displaySmall?.copyWith(color: theme.colorScheme.primary, fontWeight: FontWeight.w600), ), + const SizedBox(width: 8.0), + Obx( + () => NamidaIconButton( + icon: settings.heatmapListensView.value ? Broken.row_vertical : Broken.calendar_1, + iconSize: settings.heatmapListensView.value ? 18.0 : 20.0, + onPressed: () => settings.save(heatmapListensView: !settings.heatmapListensView.value), + ), + ), ], child: SizedBox( height: Get.height * 0.5, width: Get.width, - child: NamidaListView( - padding: EdgeInsets.zero, - itemBuilder: (context, i) { - final t = datesOfListen![i]; - return SmallListTile( - key: ValueKey(i), - borderRadius: 14.0, - title: t.dateAndClockFormattedOriginal, - leading: Container( - padding: const EdgeInsets.symmetric(horizontal: 5.0, vertical: 1.0), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8.0.multipliedRadius), - color: theme.cardColor, - ), - child: Text((datesOfListen.length - i).toString())), - onTap: () async { - final scrollInfo = HistoryController.inst.getListenScrollPosition( - listenMS: t, - extraItemsOffset: 2, - ); - NamidaOnTaps.inst.onHistoryPlaylistTap( - indexToHighlight: scrollInfo.indexOfSmallList, - dayOfHighLight: scrollInfo.dayToHighLight, - initialScrollOffset: (scrollInfo.itemsToScroll * Dimensions.inst.trackTileItemExtent) + (scrollInfo.daysToScroll * kHistoryDayHeaderHeightWithPadding), - ); - }, - ); - }, - itemCount: datesOfListen.length, - itemExtents: null, + child: Obx( + () => settings.heatmapListensView.value + ? Padding( + padding: const EdgeInsets.all(12.0), + child: PagedVerticalCalendar( + minDate: firstListen.subtract(const Duration(days: 8)), + maxDate: lastListen.add(const Duration(days: 8)), + initialDate: lastListen, + invisibleMonthsThreshold: 3, + startWeekWithSunday: true, + onDayPressed: (value) => datesMapByDay[value] == null ? null : () => onListenTap(value.millisecondsSinceEpoch), + monthBuilder: (context, month, year) { + final monthDate = DateTime(year, month); + final monthListens = datesMapByMonth[monthDate]?.length ?? 0; + final monthListensText = monthListens > 0 ? ' ($monthListens)' : ''; + final dots = (monthListens / 5).ceil(); + final monthsDateJiffy = Jiffy.parseFromDateTime(monthDate); + final monthsAgo = monthsDateJiffy.fromNow(); + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.filled( + dots, + Padding( + padding: const EdgeInsets.all(2.0), + child: CircleAvatar( + backgroundColor: color, + maxRadius: 5.0, + minRadius: 2.0, + ), + ), + ), + ), + ), + Text( + monthsDateJiffy.format(pattern: 'MMMM yyyy'), + style: Get.textTheme.titleLarge, + ), + Text( + '$monthsAgo$monthListensText', + style: Get.textTheme.displaySmall, + ), + ], + ), + ); + }, + dayBuilder: (context, date) { + final isToday = date.toDaysSince1970() == DateTime.now().toDaysSince1970(); + final listens = datesMapByDay[date]?.length ?? 0; + return NamidaInkWell( + decoration: BoxDecoration(border: isToday ? Border.all(color: color) : null), + margin: const EdgeInsets.all(2.0), + bgColor: color.withAlpha((listens * 5).clamp(0, 255)), // *5 since 50 listens a days is already a lot + borderRadius: 6.0, + onTap: datesMapByDay[date] == null ? null : () => onListenTap(date.millisecondsSinceEpoch), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text("${date.day}", style: Get.textTheme.displaySmall), + if (listens > 0) ...[ + const SizedBox(height: 2.0), + Text("$listens", style: Get.textTheme.displaySmall?.copyWith(fontSize: 9.0.multipliedFontScale)), + ] + ], + ), + ); + }, + )) + : NamidaListView( + padding: EdgeInsets.zero, + itemBuilder: (context, i) { + final t = datesOfListen[i]; + return SmallListTile( + key: ValueKey(i), + borderRadius: 14.0, + title: t.dateAndClockFormattedOriginal, + subtitle: Jiffy.parseFromMillisecondsSinceEpoch(t).fromNow(), + leading: Container( + padding: const EdgeInsets.symmetric(horizontal: 5.0, vertical: 1.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8.0.multipliedRadius), + color: theme.cardColor, + ), + child: Text((datesOfListen.length - i).toString())), + onTap: () => onListenTap(t), + ); + }, + itemCount: datesOfListen.length, + itemExtents: null, + ), ), ), ), diff --git a/lib/youtube/functions/video_listens_dialog.dart b/lib/youtube/functions/video_listens_dialog.dart index 2429dbf1..7f47819b 100644 --- a/lib/youtube/functions/video_listens_dialog.dart +++ b/lib/youtube/functions/video_listens_dialog.dart @@ -1,73 +1,28 @@ import 'package:flutter/material.dart'; -import 'package:get/get.dart'; - -import 'package:namida/controller/navigator_controller.dart'; import 'package:namida/core/dimensions.dart'; -import 'package:namida/core/extensions.dart'; -import 'package:namida/core/translations/language.dart'; -import 'package:namida/ui/widgets/custom_widgets.dart'; +import 'package:namida/ui/dialogs/track_listens_dialog.dart'; import 'package:namida/youtube/controller/youtube_history_controller.dart'; import 'package:namida/youtube/yt_utils.dart'; -void showVideoListensDialog(String videoId, {List? datesOfListen, Color? colorScheme}) async { - datesOfListen ??= YoutubeHistoryController.inst.topTracksMapListens[videoId] ?? []; - - if (datesOfListen.isEmpty) return; - datesOfListen.sortByReverse((e) => e); - - NamidaNavigator.inst.navigateDialog( - colorScheme: colorScheme, - lighterDialogColor: false, - dialogBuilder: (theme) => CustomBlurryDialog( - theme: theme, - normalTitleStyle: true, - title: lang.TOTAL_LISTENS, - trailingWidgets: [ - Text( - '${datesOfListen!.length}', - style: Get.textTheme.displaySmall?.copyWith(color: theme.colorScheme.primary, fontWeight: FontWeight.w600), - ), - ], - child: SizedBox( - height: Get.height * 0.5, - width: Get.width, - child: NamidaListView( - padding: EdgeInsets.zero, - itemBuilder: (context, i) { - final t = datesOfListen![i]; - return SmallListTile( - key: ValueKey(i), - borderRadius: 14.0, - title: t.dateAndClockFormattedOriginal, - leading: Container( - padding: const EdgeInsets.symmetric(horizontal: 5.0, vertical: 1.0), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8.0.multipliedRadius), - color: theme.cardColor, - ), - child: Text((datesOfListen.length - i).toString())), - onTap: () async { - final scrollInfo = YoutubeHistoryController.inst.getListenScrollPosition( - listenMS: t, - extraItemsOffset: 2, - ); +void showVideoListensDialog(String videoId, {List datesOfListen = const [], Color? colorScheme}) async { + showListensDialog( + datesOfListen: datesOfListen.isNotEmpty ? datesOfListen : YoutubeHistoryController.inst.topTracksMapListens[videoId] ?? [], + colorScheme: () => colorScheme, + onListenTap: (listen) { + final scrollInfo = YoutubeHistoryController.inst.getListenScrollPosition( + listenMS: listen, + extraItemsOffset: 2, + ); - final totalItemsExtent = scrollInfo.itemsToScroll * Dimensions.youtubeCardItemExtent; - final totalDaysExtent = scrollInfo.daysToScroll * kYoutubeHistoryDayHeaderHeightWithPadding; + final totalItemsExtent = scrollInfo.itemsToScroll * Dimensions.youtubeCardItemExtent; + final totalDaysExtent = scrollInfo.daysToScroll * kYoutubeHistoryDayHeaderHeightWithPadding; - YTUtils.onYoutubeHistoryPlaylistTap( - indexToHighlight: scrollInfo.indexOfSmallList, - dayOfHighLight: scrollInfo.dayToHighLight, - initialScrollOffset: totalItemsExtent + totalDaysExtent, - ); - }, - ); - }, - itemCount: datesOfListen.length, - itemExtents: null, - ), - ), - ), + YTUtils.onYoutubeHistoryPlaylistTap( + indexToHighlight: scrollInfo.indexOfSmallList, + dayOfHighLight: scrollInfo.dayToHighLight, + initialScrollOffset: totalItemsExtent + totalDaysExtent, + ); + }, ); }