♻️ Refactored heatmap

This commit is contained in:
2025-10-12 20:58:41 +08:00
parent ad9fb0719a
commit d7ca41e946
6 changed files with 321 additions and 281 deletions

View File

@@ -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<SnPublisherHeatmapItem> items,
required List<SnHeatmapItem> items,
}) = _SnPublisherHeatmap;
factory SnPublisherHeatmap.fromJson(Map<String, dynamic> json) =>
factory SnHeatmap.fromJson(Map<String, dynamic> 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<String, dynamic> json) =>
factory SnHeatmapItem.fromJson(Map<String, dynamic> json) =>
_$SnPublisherHeatmapItemFromJson(json);
}

View File

@@ -15,12 +15,12 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$SnPublisherHeatmap {
String get unit;@JsonKey(name: 'period_start') DateTime get periodStart;@JsonKey(name: 'period_end') DateTime get periodEnd; List<SnPublisherHeatmapItem> get items;
String get unit;@JsonKey(name: 'period_start') DateTime get periodStart;@JsonKey(name: 'period_end') DateTime get periodEnd; List<SnHeatmapItem> 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<SnPublisherHeatmap> get copyWith => _$SnPublisherHeatmapCopyWithImpl<SnPublisherHeatmap>(this as SnPublisherHeatmap, _$identity);
$SnPublisherHeatmapCopyWith<SnHeatmap> get copyWith => _$SnPublisherHeatmapCopyWithImpl<SnHeatmap>(this as SnHeatmap, _$identity);
/// Serializes this SnPublisherHeatmap to a JSON map.
Map<String, dynamic> toJson();
@@ -28,7 +28,7 @@ $SnPublisherHeatmapCopyWith<SnPublisherHeatmap> 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<SnPublisherHeatmapItem> items
String unit,@JsonKey(name: 'period_start') DateTime periodStart,@JsonKey(name: 'period_end') DateTime periodEnd, List<SnHeatmapItem> 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<SnPublisherHeatmapItem>,
as List<SnHeatmapItem>,
));
}
}
/// 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 extends Object?>(TResult Function( String unit, @JsonKey(name: 'period_start') DateTime periodStart, @JsonKey(name: 'period_end') DateTime periodEnd, List<SnPublisherHeatmapItem> items)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String unit, @JsonKey(name: 'period_start') DateTime periodStart, @JsonKey(name: 'period_end') DateTime periodEnd, List<SnHeatmapItem> 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 extends Object?>(TResult Function( String unit, @JsonKey(name: 'period_start') DateTime periodStart, @JsonKey(name: 'period_end') DateTime periodEnd, List<SnPublisherHeatmapItem> items) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String unit, @JsonKey(name: 'period_start') DateTime periodStart, @JsonKey(name: 'period_end') DateTime periodEnd, List<SnHeatmapItem> 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 extends Object?>(TResult? Function( String unit, @JsonKey(name: 'period_start') DateTime periodStart, @JsonKey(name: 'period_end') DateTime periodEnd, List<SnPublisherHeatmapItem> items)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String unit, @JsonKey(name: 'period_start') DateTime periodStart, @JsonKey(name: 'period_end') DateTime periodEnd, List<SnHeatmapItem> 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<SnPublisherHeatmapItem> 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<SnHeatmapItem> items}): _items = items;
factory _SnPublisherHeatmap.fromJson(Map<String, dynamic> 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<SnPublisherHeatmapItem> _items;
@override List<SnPublisherHeatmapItem> get items {
final List<SnHeatmapItem> _items;
@override List<SnHeatmapItem> 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<SnPublisherHeatmapItem> items
String unit,@JsonKey(name: 'period_start') DateTime periodStart,@JsonKey(name: 'period_end') DateTime periodEnd, List<SnHeatmapItem> 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<SnPublisherHeatmapItem>,
as List<SnHeatmapItem>,
));
}
@@ -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<SnPublisherHeatmapItem> get copyWith => _$SnPublisherHeatmapItemCopyWithImpl<SnPublisherHeatmapItem>(this as SnPublisherHeatmapItem, _$identity);
$SnPublisherHeatmapItemCopyWith<SnHeatmapItem> get copyWith => _$SnPublisherHeatmapItemCopyWithImpl<SnHeatmapItem>(this as SnHeatmapItem, _$identity);
/// Serializes this SnPublisherHeatmapItem to a JSON map.
Map<String, dynamic> toJson();
@@ -300,7 +300,7 @@ $SnPublisherHeatmapItemCopyWith<SnPublisherHeatmapItem> 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<String, dynamic> json) => _$SnPublisherHeatmapItemFromJson(json);

View File

@@ -13,10 +13,7 @@ _SnPublisherHeatmap _$SnPublisherHeatmapFromJson(Map<String, dynamic> json) =>
periodEnd: DateTime.parse(json['period_end'] as String),
items:
(json['items'] as List<dynamic>)
.map(
(e) =>
SnPublisherHeatmapItem.fromJson(e as Map<String, dynamic>),
)
.map((e) => SnHeatmapItem.fromJson(e as Map<String, dynamic>))
.toList(),
);

View File

@@ -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<SnPublisherStats?> publisherStats(Ref ref, String? uname) async {
}
@riverpod
Future<SnPublisherHeatmap?> publisherHeatmap(Ref ref, String? uname) async {
Future<SnHeatmap?> 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 = <DateTime>[];
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 = <DateTime, double>{};
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 = <String>[];
final monthPositions = <int>[];
final processedMonths =
<String>{}; // 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];
}
}

View File

@@ -156,7 +156,7 @@ String _$publisherHeatmapHash() => r'780dfb05b8610a37cfcd937fd04cf5bbe9b298c9';
const publisherHeatmapProvider = PublisherHeatmapFamily();
/// See also [publisherHeatmap].
class PublisherHeatmapFamily extends Family<AsyncValue<SnPublisherHeatmap?>> {
class PublisherHeatmapFamily extends Family<AsyncValue<SnHeatmap?>> {
/// See also [publisherHeatmap].
const PublisherHeatmapFamily();
@@ -188,8 +188,7 @@ class PublisherHeatmapFamily extends Family<AsyncValue<SnPublisherHeatmap?>> {
}
/// See also [publisherHeatmap].
class PublisherHeatmapProvider
extends AutoDisposeFutureProvider<SnPublisherHeatmap?> {
class PublisherHeatmapProvider extends AutoDisposeFutureProvider<SnHeatmap?> {
/// See also [publisherHeatmap].
PublisherHeatmapProvider(String? uname)
: this._internal(
@@ -220,7 +219,7 @@ class PublisherHeatmapProvider
@override
Override overrideWith(
FutureOr<SnPublisherHeatmap?> Function(PublisherHeatmapRef provider) create,
FutureOr<SnHeatmap?> Function(PublisherHeatmapRef provider) create,
) {
return ProviderOverride(
origin: this,
@@ -237,7 +236,7 @@ class PublisherHeatmapProvider
}
@override
AutoDisposeFutureProviderElement<SnPublisherHeatmap?> createElement() {
AutoDisposeFutureProviderElement<SnHeatmap?> 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<SnPublisherHeatmap?> {
mixin PublisherHeatmapRef on AutoDisposeFutureProviderRef<SnHeatmap?> {
/// The parameter `uname` of this provider.
String? get uname;
}
class _PublisherHeatmapProviderElement
extends AutoDisposeFutureProviderElement<SnPublisherHeatmap?>
extends AutoDisposeFutureProviderElement<SnHeatmap?>
with PublisherHeatmapRef {
_PublisherHeatmapProviderElement(super.provider);

View File

@@ -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<HeatmapItem?>(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 = <DateTime>[];
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 = <DateTime, double>{};
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 = <String>[];
final monthPositions = <int>[];
final processedMonths =
<String>{}; // 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
}
}
}