From d7ca41e946a8b81343edb99dbe95f098d06b3801 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 12 Oct 2025 20:58:41 +0800 Subject: [PATCH] :recycle: Refactored heatmap --- lib/models/heatmap.dart | 18 +- lib/models/heatmap.freezed.dart | 54 +++--- lib/models/heatmap.g.dart | 5 +- lib/screens/creators/hub.dart | 238 +------------------------- lib/screens/creators/hub.g.dart | 13 +- lib/widgets/activity_heatmap.dart | 274 ++++++++++++++++++++++++++++++ 6 files changed, 321 insertions(+), 281 deletions(-) create mode 100644 lib/widgets/activity_heatmap.dart diff --git a/lib/models/heatmap.dart b/lib/models/heatmap.dart index cf10f58d..7b99751a 100644 --- a/lib/models/heatmap.dart +++ b/lib/models/heatmap.dart @@ -4,25 +4,23 @@ part 'heatmap.freezed.dart'; part 'heatmap.g.dart'; @freezed -sealed class SnPublisherHeatmap with _$SnPublisherHeatmap { - const factory SnPublisherHeatmap({ +sealed class SnHeatmap with _$SnPublisherHeatmap { + const factory SnHeatmap({ required String unit, @JsonKey(name: 'period_start') required DateTime periodStart, @JsonKey(name: 'period_end') required DateTime periodEnd, - required List items, + required List items, }) = _SnPublisherHeatmap; - factory SnPublisherHeatmap.fromJson(Map json) => + factory SnHeatmap.fromJson(Map json) => _$SnPublisherHeatmapFromJson(json); } @freezed -sealed class SnPublisherHeatmapItem with _$SnPublisherHeatmapItem { - const factory SnPublisherHeatmapItem({ - required DateTime date, - required int count, - }) = _SnPublisherHeatmapItem; +sealed class SnHeatmapItem with _$SnPublisherHeatmapItem { + const factory SnHeatmapItem({required DateTime date, required int count}) = + _SnPublisherHeatmapItem; - factory SnPublisherHeatmapItem.fromJson(Map json) => + factory SnHeatmapItem.fromJson(Map json) => _$SnPublisherHeatmapItemFromJson(json); } diff --git a/lib/models/heatmap.freezed.dart b/lib/models/heatmap.freezed.dart index 748ba88e..30b6f8f1 100644 --- a/lib/models/heatmap.freezed.dart +++ b/lib/models/heatmap.freezed.dart @@ -15,12 +15,12 @@ T _$identity(T value) => value; /// @nodoc mixin _$SnPublisherHeatmap { - String get unit;@JsonKey(name: 'period_start') DateTime get periodStart;@JsonKey(name: 'period_end') DateTime get periodEnd; List get items; + String get unit;@JsonKey(name: 'period_start') DateTime get periodStart;@JsonKey(name: 'period_end') DateTime get periodEnd; List get items; /// Create a copy of SnPublisherHeatmap /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @pragma('vm:prefer-inline') -$SnPublisherHeatmapCopyWith get copyWith => _$SnPublisherHeatmapCopyWithImpl(this as SnPublisherHeatmap, _$identity); +$SnPublisherHeatmapCopyWith get copyWith => _$SnPublisherHeatmapCopyWithImpl(this as SnHeatmap, _$identity); /// Serializes this SnPublisherHeatmap to a JSON map. Map toJson(); @@ -28,7 +28,7 @@ $SnPublisherHeatmapCopyWith get copyWith => _$SnPublisherHea @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is SnPublisherHeatmap&&(identical(other.unit, unit) || other.unit == unit)&&(identical(other.periodStart, periodStart) || other.periodStart == periodStart)&&(identical(other.periodEnd, periodEnd) || other.periodEnd == periodEnd)&&const DeepCollectionEquality().equals(other.items, items)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is SnHeatmap&&(identical(other.unit, unit) || other.unit == unit)&&(identical(other.periodStart, periodStart) || other.periodStart == periodStart)&&(identical(other.periodEnd, periodEnd) || other.periodEnd == periodEnd)&&const DeepCollectionEquality().equals(other.items, items)); } @JsonKey(includeFromJson: false, includeToJson: false) @@ -45,10 +45,10 @@ String toString() { /// @nodoc abstract mixin class $SnPublisherHeatmapCopyWith<$Res> { - factory $SnPublisherHeatmapCopyWith(SnPublisherHeatmap value, $Res Function(SnPublisherHeatmap) _then) = _$SnPublisherHeatmapCopyWithImpl; + factory $SnPublisherHeatmapCopyWith(SnHeatmap value, $Res Function(SnHeatmap) _then) = _$SnPublisherHeatmapCopyWithImpl; @useResult $Res call({ - String unit,@JsonKey(name: 'period_start') DateTime periodStart,@JsonKey(name: 'period_end') DateTime periodEnd, List items + String unit,@JsonKey(name: 'period_start') DateTime periodStart,@JsonKey(name: 'period_end') DateTime periodEnd, List items }); @@ -60,8 +60,8 @@ class _$SnPublisherHeatmapCopyWithImpl<$Res> implements $SnPublisherHeatmapCopyWith<$Res> { _$SnPublisherHeatmapCopyWithImpl(this._self, this._then); - final SnPublisherHeatmap _self; - final $Res Function(SnPublisherHeatmap) _then; + final SnHeatmap _self; + final $Res Function(SnHeatmap) _then; /// Create a copy of SnPublisherHeatmap /// with the given fields replaced by the non-null parameter values. @@ -71,15 +71,15 @@ unit: null == unit ? _self.unit : unit // ignore: cast_nullable_to_non_nullable as String,periodStart: null == periodStart ? _self.periodStart : periodStart // ignore: cast_nullable_to_non_nullable as DateTime,periodEnd: null == periodEnd ? _self.periodEnd : periodEnd // ignore: cast_nullable_to_non_nullable as DateTime,items: null == items ? _self.items : items // ignore: cast_nullable_to_non_nullable -as List, +as List, )); } } -/// Adds pattern-matching-related methods to [SnPublisherHeatmap]. -extension SnPublisherHeatmapPatterns on SnPublisherHeatmap { +/// Adds pattern-matching-related methods to [SnHeatmap]. +extension SnPublisherHeatmapPatterns on SnHeatmap { /// A variant of `map` that fallback to returning `orElse`. /// /// It is equivalent to doing: @@ -153,7 +153,7 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String unit, @JsonKey(name: 'period_start') DateTime periodStart, @JsonKey(name: 'period_end') DateTime periodEnd, List items)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String unit, @JsonKey(name: 'period_start') DateTime periodStart, @JsonKey(name: 'period_end') DateTime periodEnd, List items)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _SnPublisherHeatmap() when $default != null: return $default(_that.unit,_that.periodStart,_that.periodEnd,_that.items);case _: @@ -174,7 +174,7 @@ return $default(_that.unit,_that.periodStart,_that.periodEnd,_that.items);case _ /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String unit, @JsonKey(name: 'period_start') DateTime periodStart, @JsonKey(name: 'period_end') DateTime periodEnd, List items) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String unit, @JsonKey(name: 'period_start') DateTime periodStart, @JsonKey(name: 'period_end') DateTime periodEnd, List items) $default,) {final _that = this; switch (_that) { case _SnPublisherHeatmap(): return $default(_that.unit,_that.periodStart,_that.periodEnd,_that.items);} @@ -191,7 +191,7 @@ return $default(_that.unit,_that.periodStart,_that.periodEnd,_that.items);} /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String unit, @JsonKey(name: 'period_start') DateTime periodStart, @JsonKey(name: 'period_end') DateTime periodEnd, List items)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String unit, @JsonKey(name: 'period_start') DateTime periodStart, @JsonKey(name: 'period_end') DateTime periodEnd, List items)? $default,) {final _that = this; switch (_that) { case _SnPublisherHeatmap() when $default != null: return $default(_that.unit,_that.periodStart,_that.periodEnd,_that.items);case _: @@ -205,15 +205,15 @@ return $default(_that.unit,_that.periodStart,_that.periodEnd,_that.items);case _ /// @nodoc @JsonSerializable() -class _SnPublisherHeatmap implements SnPublisherHeatmap { - const _SnPublisherHeatmap({required this.unit, @JsonKey(name: 'period_start') required this.periodStart, @JsonKey(name: 'period_end') required this.periodEnd, required final List items}): _items = items; +class _SnPublisherHeatmap implements SnHeatmap { + const _SnPublisherHeatmap({required this.unit, @JsonKey(name: 'period_start') required this.periodStart, @JsonKey(name: 'period_end') required this.periodEnd, required final List items}): _items = items; factory _SnPublisherHeatmap.fromJson(Map json) => _$SnPublisherHeatmapFromJson(json); @override final String unit; @override@JsonKey(name: 'period_start') final DateTime periodStart; @override@JsonKey(name: 'period_end') final DateTime periodEnd; - final List _items; -@override List get items { + final List _items; +@override List get items { if (_items is EqualUnmodifiableListView) return _items; // ignore: implicit_dynamic_type return EqualUnmodifiableListView(_items); @@ -253,7 +253,7 @@ abstract mixin class _$SnPublisherHeatmapCopyWith<$Res> implements $SnPublisherH factory _$SnPublisherHeatmapCopyWith(_SnPublisherHeatmap value, $Res Function(_SnPublisherHeatmap) _then) = __$SnPublisherHeatmapCopyWithImpl; @override @useResult $Res call({ - String unit,@JsonKey(name: 'period_start') DateTime periodStart,@JsonKey(name: 'period_end') DateTime periodEnd, List items + String unit,@JsonKey(name: 'period_start') DateTime periodStart,@JsonKey(name: 'period_end') DateTime periodEnd, List items }); @@ -276,7 +276,7 @@ unit: null == unit ? _self.unit : unit // ignore: cast_nullable_to_non_nullable as String,periodStart: null == periodStart ? _self.periodStart : periodStart // ignore: cast_nullable_to_non_nullable as DateTime,periodEnd: null == periodEnd ? _self.periodEnd : periodEnd // ignore: cast_nullable_to_non_nullable as DateTime,items: null == items ? _self._items : items // ignore: cast_nullable_to_non_nullable -as List, +as List, )); } @@ -292,7 +292,7 @@ mixin _$SnPublisherHeatmapItem { /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @pragma('vm:prefer-inline') -$SnPublisherHeatmapItemCopyWith get copyWith => _$SnPublisherHeatmapItemCopyWithImpl(this as SnPublisherHeatmapItem, _$identity); +$SnPublisherHeatmapItemCopyWith get copyWith => _$SnPublisherHeatmapItemCopyWithImpl(this as SnHeatmapItem, _$identity); /// Serializes this SnPublisherHeatmapItem to a JSON map. Map toJson(); @@ -300,7 +300,7 @@ $SnPublisherHeatmapItemCopyWith get copyWith => _$SnPubl @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is SnPublisherHeatmapItem&&(identical(other.date, date) || other.date == date)&&(identical(other.count, count) || other.count == count)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is SnHeatmapItem&&(identical(other.date, date) || other.date == date)&&(identical(other.count, count) || other.count == count)); } @JsonKey(includeFromJson: false, includeToJson: false) @@ -317,7 +317,7 @@ String toString() { /// @nodoc abstract mixin class $SnPublisherHeatmapItemCopyWith<$Res> { - factory $SnPublisherHeatmapItemCopyWith(SnPublisherHeatmapItem value, $Res Function(SnPublisherHeatmapItem) _then) = _$SnPublisherHeatmapItemCopyWithImpl; + factory $SnPublisherHeatmapItemCopyWith(SnHeatmapItem value, $Res Function(SnHeatmapItem) _then) = _$SnPublisherHeatmapItemCopyWithImpl; @useResult $Res call({ DateTime date, int count @@ -332,8 +332,8 @@ class _$SnPublisherHeatmapItemCopyWithImpl<$Res> implements $SnPublisherHeatmapItemCopyWith<$Res> { _$SnPublisherHeatmapItemCopyWithImpl(this._self, this._then); - final SnPublisherHeatmapItem _self; - final $Res Function(SnPublisherHeatmapItem) _then; + final SnHeatmapItem _self; + final $Res Function(SnHeatmapItem) _then; /// Create a copy of SnPublisherHeatmapItem /// with the given fields replaced by the non-null parameter values. @@ -348,8 +348,8 @@ as int, } -/// Adds pattern-matching-related methods to [SnPublisherHeatmapItem]. -extension SnPublisherHeatmapItemPatterns on SnPublisherHeatmapItem { +/// Adds pattern-matching-related methods to [SnHeatmapItem]. +extension SnPublisherHeatmapItemPatterns on SnHeatmapItem { /// A variant of `map` that fallback to returning `orElse`. /// /// It is equivalent to doing: @@ -475,7 +475,7 @@ return $default(_that.date,_that.count);case _: /// @nodoc @JsonSerializable() -class _SnPublisherHeatmapItem implements SnPublisherHeatmapItem { +class _SnPublisherHeatmapItem implements SnHeatmapItem { const _SnPublisherHeatmapItem({required this.date, required this.count}); factory _SnPublisherHeatmapItem.fromJson(Map json) => _$SnPublisherHeatmapItemFromJson(json); diff --git a/lib/models/heatmap.g.dart b/lib/models/heatmap.g.dart index ab8e3fd6..9bec7fd3 100644 --- a/lib/models/heatmap.g.dart +++ b/lib/models/heatmap.g.dart @@ -13,10 +13,7 @@ _SnPublisherHeatmap _$SnPublisherHeatmapFromJson(Map json) => periodEnd: DateTime.parse(json['period_end'] as String), items: (json['items'] as List) - .map( - (e) => - SnPublisherHeatmapItem.fromJson(e as Map), - ) + .map((e) => SnHeatmapItem.fromJson(e as Map)) .toList(), ); diff --git a/lib/screens/creators/hub.dart b/lib/screens/creators/hub.dart index 32ac30dd..9e55238d 100644 --- a/lib/screens/creators/hub.dart +++ b/lib/screens/creators/hub.dart @@ -1,7 +1,6 @@ import 'package:dio/dio.dart'; import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:fl_heatmap/fl_heatmap.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -20,6 +19,7 @@ import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/response.dart'; +import 'package:island/widgets/activity_heatmap.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; @@ -36,11 +36,11 @@ Future publisherStats(Ref ref, String? uname) async { } @riverpod -Future publisherHeatmap(Ref ref, String? uname) async { +Future publisherHeatmap(Ref ref, String? uname) async { if (uname == null) return null; final apiClient = ref.watch(apiClientProvider); final resp = await apiClient.get('/sphere/publishers/$uname/heatmap'); - return SnPublisherHeatmap.fromJson(resp.data); + return SnHeatmap.fromJson(resp.data); } @riverpod @@ -601,7 +601,7 @@ class CreatorHubScreen extends HookConsumerWidget { class _PublisherStatsWidget extends StatelessWidget { final SnPublisherStats stats; - final SnPublisherHeatmap? heatmap; + final SnHeatmap? heatmap; const _PublisherStatsWidget({required this.stats, this.heatmap}); @override @@ -655,7 +655,7 @@ class _PublisherStatsWidget extends StatelessWidget { ), ], ), - if (heatmap != null) _PublisherHeatmapWidget(heatmap: heatmap!), + if (heatmap != null) ActivityHeatmapWidget(heatmap: heatmap!), ], ), ); @@ -1197,231 +1197,3 @@ class _PublisherInviteSheet extends HookConsumerWidget { ); } } - -class _PublisherHeatmapWidget extends StatelessWidget { - final SnPublisherHeatmap heatmap; - const _PublisherHeatmapWidget({required this.heatmap}); - - @override - Widget build(BuildContext context) { - // Generate exactly 365 days ending at current date - final now = DateTime.now(); - - // Start from exactly 365 days ago - final startDate = now.subtract(const Duration(days: 365)); - // End at current date - final endDate = now; - - // Find monday of the week containing start date - final startMonday = startDate.subtract( - Duration(days: startDate.weekday - 1), - ); - // Find sunday of the week containing end date - final endSunday = endDate.add(Duration(days: 7 - endDate.weekday)); - - // Generate weeks to cover exactly 365 days - final weeks = []; - var current = startMonday; - while (current.isBefore(endSunday) || current.isAtSameMomentAs(endSunday)) { - weeks.add(current); - current = current.add(const Duration(days: 7)); - } - - // Create data map for all dates in the range - final dataMap = {}; - for (final week in weeks) { - for (var i = 0; i < 7; i++) { - final date = week.add(Duration(days: i)); - // Only include dates within our 365-day range - if (date.isAfter(startDate.subtract(const Duration(days: 1))) && - date.isBefore(endDate.add(const Duration(days: 1)))) { - final item = heatmap.items.firstWhere( - (e) => - e.date.year == date.year && - e.date.month == date.month && - e.date.day == date.day, - orElse: () => SnPublisherHeatmapItem(date: date, count: 0), - ); - dataMap[date] = item.count.toDouble(); - } - } - } - - // Generate month labels for the top - final monthLabels = []; - final monthPositions = []; - final processedMonths = - {}; // Track processed months to avoid duplicates - - for (final week in weeks) { - final monthKey = '${week.year}-${week.month.toString().padLeft(2, '0')}'; - - // Only process each month once - if (!processedMonths.contains(monthKey)) { - processedMonths.add(monthKey); - - // Find which week this month starts in - final firstDayOfMonth = DateTime(week.year, week.month, 1); - final monthStartMonday = firstDayOfMonth.subtract( - Duration(days: firstDayOfMonth.weekday - 1), - ); - - final monthStartWeekIndex = weeks.indexWhere( - (w) => - w.year == monthStartMonday.year && - w.month == monthStartMonday.month && - w.day == monthStartMonday.day, - ); - - if (monthStartWeekIndex != -1) { - monthLabels.add(_getMonthAbbreviation(week.month)); - monthPositions.add(monthStartWeekIndex); - } - } - } - - final heatmapData = HeatmapData( - rows: [ - 'Mon', - 'Tue', - 'Wed', - 'Thu', - 'Fri', - 'Sat', - 'Sun', - ], // Days of week vertically - columns: - weeks - .map( - (w) => - '${w.year}-${w.month.toString().padLeft(2, '0')}-${w.day.toString().padLeft(2, '0')}', - ) - .toList(), // Weeks horizontally - items: [ - for (int day = 0; day < 7; day++) // For each day of week (Mon-Sun) - for (final week in weeks) // For each week - HeatmapItem( - value: dataMap[week.add(Duration(days: day))] ?? 0.0, - unit: heatmap.unit, - xAxisLabel: - '${week.year}-${week.month.toString().padLeft(2, '0')}-${week.day.toString().padLeft(2, '0')}', - yAxisLabel: - day == 0 - ? 'Mon' - : day == 1 - ? 'Tue' - : day == 2 - ? 'Wed' - : day == 3 - ? 'Thu' - : day == 4 - ? 'Fri' - : day == 5 - ? 'Sat' - : 'Sun', - ), - ], - ); - - return Card( - margin: EdgeInsets.zero, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'activityHeatmap', - style: Theme.of(context).textTheme.titleMedium, - ).tr(), - const Gap(8), - // Month labels row - Row( - children: [ - const SizedBox(width: 30), // Space for day labels - ...monthLabels.asMap().entries.map((entry) { - final monthIndex = entry.key; - final month = entry.value; - - return Expanded( - child: Container( - alignment: Alignment.center, - child: Text( - month, - style: Theme.of(context).textTheme.bodySmall, - textAlign: TextAlign.center, - ), - ), - ); - }), - ], - ), - const Gap(4), - Heatmap( - heatmapData: heatmapData, - rowsVisible: 7, - showXAxisLabels: false, - ), - const Gap(8), - // Legend - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Text( - 'Less', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const Gap(4), - // Color indicators (light to dark green) - ...[ - Colors.green.withOpacity(0.2), - Colors.green.withOpacity(0.4), - Colors.green.withOpacity(0.6), - Colors.green.withOpacity(0.8), - Colors.green, - ].map( - (color) => Container( - width: 8, - height: 8, - margin: const EdgeInsets.symmetric(horizontal: 1), - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - const Gap(4), - Text( - 'More', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ], - ), - ), - ); - } - - String _getMonthAbbreviation(int month) { - const monthNames = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ]; - return monthNames[month - 1]; - } -} diff --git a/lib/screens/creators/hub.g.dart b/lib/screens/creators/hub.g.dart index a2291f72..9fc985dd 100644 --- a/lib/screens/creators/hub.g.dart +++ b/lib/screens/creators/hub.g.dart @@ -156,7 +156,7 @@ String _$publisherHeatmapHash() => r'780dfb05b8610a37cfcd937fd04cf5bbe9b298c9'; const publisherHeatmapProvider = PublisherHeatmapFamily(); /// See also [publisherHeatmap]. -class PublisherHeatmapFamily extends Family> { +class PublisherHeatmapFamily extends Family> { /// See also [publisherHeatmap]. const PublisherHeatmapFamily(); @@ -188,8 +188,7 @@ class PublisherHeatmapFamily extends Family> { } /// See also [publisherHeatmap]. -class PublisherHeatmapProvider - extends AutoDisposeFutureProvider { +class PublisherHeatmapProvider extends AutoDisposeFutureProvider { /// See also [publisherHeatmap]. PublisherHeatmapProvider(String? uname) : this._internal( @@ -220,7 +219,7 @@ class PublisherHeatmapProvider @override Override overrideWith( - FutureOr Function(PublisherHeatmapRef provider) create, + FutureOr Function(PublisherHeatmapRef provider) create, ) { return ProviderOverride( origin: this, @@ -237,7 +236,7 @@ class PublisherHeatmapProvider } @override - AutoDisposeFutureProviderElement createElement() { + AutoDisposeFutureProviderElement createElement() { return _PublisherHeatmapProviderElement(this); } @@ -257,13 +256,13 @@ class PublisherHeatmapProvider @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element -mixin PublisherHeatmapRef on AutoDisposeFutureProviderRef { +mixin PublisherHeatmapRef on AutoDisposeFutureProviderRef { /// The parameter `uname` of this provider. String? get uname; } class _PublisherHeatmapProviderElement - extends AutoDisposeFutureProviderElement + extends AutoDisposeFutureProviderElement with PublisherHeatmapRef { _PublisherHeatmapProviderElement(super.provider); diff --git a/lib/widgets/activity_heatmap.dart b/lib/widgets/activity_heatmap.dart new file mode 100644 index 00000000..3127b6d5 --- /dev/null +++ b/lib/widgets/activity_heatmap.dart @@ -0,0 +1,274 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:fl_heatmap/fl_heatmap.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/heatmap.dart'; + +/// A reusable heatmap widget for displaying activity data in GitHub-style layout. +/// Shows exactly 365 days of data ending at the current date. +class ActivityHeatmapWidget extends HookConsumerWidget { + final SnHeatmap heatmap; + + const ActivityHeatmapWidget({super.key, required this.heatmap}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedItem = useState(null); + + // Generate exactly 365 days ending at current date + final now = DateTime.now(); + + // Start from exactly 365 days ago + final startDate = now.subtract(const Duration(days: 365)); + // End at current date + final endDate = now; + + // Find monday of the week containing start date + final startMonday = startDate.subtract( + Duration(days: startDate.weekday - 1), + ); + // Find sunday of the week containing end date + final endSunday = endDate.add(Duration(days: 7 - endDate.weekday)); + + // Generate weeks to cover exactly 365 days + final weeks = []; + var current = startMonday; + while (current.isBefore(endSunday) || current.isAtSameMomentAs(endSunday)) { + weeks.add(current); + current = current.add(const Duration(days: 7)); + } + + // Create data map for all dates in the range + final dataMap = {}; + for (final week in weeks) { + for (var i = 0; i < 7; i++) { + final date = week.add(Duration(days: i)); + // Only include dates within our 365-day range + if (date.isAfter(startDate.subtract(const Duration(days: 1))) && + date.isBefore(endDate.add(const Duration(days: 1)))) { + final item = heatmap.items.firstWhere( + (e) => + e.date.year == date.year && + e.date.month == date.month && + e.date.day == date.day, + orElse: () => SnHeatmapItem(date: date, count: 0), + ); + dataMap[date] = item.count.toDouble(); + } + } + } + + // Generate month labels for the top + final monthLabels = []; + final monthPositions = []; + final processedMonths = + {}; // Track processed months to avoid duplicates + + for (final week in weeks) { + final monthKey = '${week.year}-${week.month.toString().padLeft(2, '0')}'; + + // Only process each month once + if (!processedMonths.contains(monthKey)) { + processedMonths.add(monthKey); + + // Find which week this month starts in + final firstDayOfMonth = DateTime(week.year, week.month, 1); + final monthStartMonday = firstDayOfMonth.subtract( + Duration(days: firstDayOfMonth.weekday - 1), + ); + + final monthStartWeekIndex = weeks.indexWhere( + (w) => + w.year == monthStartMonday.year && + w.month == monthStartMonday.month && + w.day == monthStartMonday.day, + ); + + if (monthStartWeekIndex != -1) { + monthLabels.add(_getMonthAbbreviation(week.month)); + monthPositions.add(monthStartWeekIndex); + } + } + } + + final heatmapData = HeatmapData( + rows: [ + 'Mon', + 'Tue', + 'Wed', + 'Thu', + 'Fri', + 'Sat', + 'Sun', + ], // Days of week vertically + columns: + weeks + .map( + (w) => + '${w.year}-${w.month.toString().padLeft(2, '0')}-${w.day.toString().padLeft(2, '0')}', + ) + .toList(), // Weeks horizontally + items: [ + for (int day = 0; day < 7; day++) // For each day of week (Mon-Sun) + for (final week in weeks) // For each week + HeatmapItem( + value: dataMap[week.add(Duration(days: day))] ?? 0.0, + unit: heatmap.unit, + xAxisLabel: + '${week.year}-${week.month.toString().padLeft(2, '0')}-${week.day.toString().padLeft(2, '0')}', + yAxisLabel: + day == 0 + ? 'Mon' + : day == 1 + ? 'Tue' + : day == 2 + ? 'Wed' + : day == 3 + ? 'Thu' + : day == 4 + ? 'Fri' + : day == 5 + ? 'Sat' + : 'Sun', + ), + ], + ); + + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'activityHeatmap', + style: Theme.of(context).textTheme.titleMedium, + ).tr(), + const Gap(8), + // Month labels row + Row( + children: [ + const SizedBox(width: 30), // Space for day labels + ...monthLabels.asMap().entries.map((entry) { + final month = entry.value; + + return Expanded( + child: Container( + alignment: Alignment.center, + child: Text( + month, + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ), + ); + }), + ], + ), + const Gap(4), + Heatmap( + heatmapData: heatmapData, + rowsVisible: 7, + showXAxisLabels: false, + onItemSelectedListener: (item) { + selectedItem.value = item; + }, + ), + const Gap(8), + // Legend + Row( + children: [ + if (selectedItem.value != null) + RichText( + text: TextSpan( + children: [ + TextSpan( + text: selectedItem.value!.value.toInt().toString(), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(fontWeight: FontWeight.bold), + ), + TextSpan( + text: ' activities on ', + style: Theme.of(context).textTheme.bodySmall, + ), + TextSpan( + text: _formatDate( + selectedItem.value!.xAxisLabel ?? '', + ), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + const Spacer(), + Text( + 'Less', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const Gap(4), + // Color indicators (light to dark green) + ...[ + Colors.green.withOpacity(0.2), + Colors.green.withOpacity(0.4), + Colors.green.withOpacity(0.6), + Colors.green.withOpacity(0.8), + Colors.green, + ].map( + (color) => Container( + width: 8, + height: 8, + margin: const EdgeInsets.symmetric(horizontal: 1), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const Gap(4), + Text( + 'More', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ], + ), + ), + ); + } + + String _getMonthAbbreviation(int month) { + const monthNames = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + return monthNames[month - 1]; + } + + String _formatDate(String dateString) { + try { + final date = DateTime.parse(dateString); + final monthAbbrev = _getMonthAbbreviation(date.month); + return '$monthAbbrev ${date.day}, ${date.year}'; + } catch (e) { + return dateString; // Fallback to original string if parsing fails + } + } +}