♻️ 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'; part 'heatmap.g.dart';
@freezed @freezed
sealed class SnPublisherHeatmap with _$SnPublisherHeatmap { sealed class SnHeatmap with _$SnPublisherHeatmap {
const factory SnPublisherHeatmap({ const factory SnHeatmap({
required String unit, required String unit,
@JsonKey(name: 'period_start') required DateTime periodStart, @JsonKey(name: 'period_start') required DateTime periodStart,
@JsonKey(name: 'period_end') required DateTime periodEnd, @JsonKey(name: 'period_end') required DateTime periodEnd,
required List<SnPublisherHeatmapItem> items, required List<SnHeatmapItem> items,
}) = _SnPublisherHeatmap; }) = _SnPublisherHeatmap;
factory SnPublisherHeatmap.fromJson(Map<String, dynamic> json) => factory SnHeatmap.fromJson(Map<String, dynamic> json) =>
_$SnPublisherHeatmapFromJson(json); _$SnPublisherHeatmapFromJson(json);
} }
@freezed @freezed
sealed class SnPublisherHeatmapItem with _$SnPublisherHeatmapItem { sealed class SnHeatmapItem with _$SnPublisherHeatmapItem {
const factory SnPublisherHeatmapItem({ const factory SnHeatmapItem({required DateTime date, required int count}) =
required DateTime date, _SnPublisherHeatmapItem;
required int count,
}) = _SnPublisherHeatmapItem;
factory SnPublisherHeatmapItem.fromJson(Map<String, dynamic> json) => factory SnHeatmapItem.fromJson(Map<String, dynamic> json) =>
_$SnPublisherHeatmapItemFromJson(json); _$SnPublisherHeatmapItemFromJson(json);
} }

View File

@@ -15,12 +15,12 @@ T _$identity<T>(T value) => value;
/// @nodoc /// @nodoc
mixin _$SnPublisherHeatmap { 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 /// Create a copy of SnPublisherHeatmap
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline') @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. /// Serializes this SnPublisherHeatmap to a JSON map.
Map<String, dynamic> toJson(); Map<String, dynamic> toJson();
@@ -28,7 +28,7 @@ $SnPublisherHeatmapCopyWith<SnPublisherHeatmap> get copyWith => _$SnPublisherHea
@override @override
bool operator ==(Object other) { 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) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -45,10 +45,10 @@ String toString() {
/// @nodoc /// @nodoc
abstract mixin class $SnPublisherHeatmapCopyWith<$Res> { abstract mixin class $SnPublisherHeatmapCopyWith<$Res> {
factory $SnPublisherHeatmapCopyWith(SnPublisherHeatmap value, $Res Function(SnPublisherHeatmap) _then) = _$SnPublisherHeatmapCopyWithImpl; factory $SnPublisherHeatmapCopyWith(SnHeatmap value, $Res Function(SnHeatmap) _then) = _$SnPublisherHeatmapCopyWithImpl;
@useResult @useResult
$Res call({ $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> { implements $SnPublisherHeatmapCopyWith<$Res> {
_$SnPublisherHeatmapCopyWithImpl(this._self, this._then); _$SnPublisherHeatmapCopyWithImpl(this._self, this._then);
final SnPublisherHeatmap _self; final SnHeatmap _self;
final $Res Function(SnPublisherHeatmap) _then; final $Res Function(SnHeatmap) _then;
/// Create a copy of SnPublisherHeatmap /// Create a copy of SnPublisherHeatmap
/// with the given fields replaced by the non-null parameter values. /// 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 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,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 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]. /// Adds pattern-matching-related methods to [SnHeatmap].
extension SnPublisherHeatmapPatterns on SnPublisherHeatmap { extension SnPublisherHeatmapPatterns on SnHeatmap {
/// A variant of `map` that fallback to returning `orElse`. /// A variant of `map` that fallback to returning `orElse`.
/// ///
/// It is equivalent to doing: /// 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) { switch (_that) {
case _SnPublisherHeatmap() when $default != null: case _SnPublisherHeatmap() when $default != null:
return $default(_that.unit,_that.periodStart,_that.periodEnd,_that.items);case _: 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) { switch (_that) {
case _SnPublisherHeatmap(): case _SnPublisherHeatmap():
return $default(_that.unit,_that.periodStart,_that.periodEnd,_that.items);} 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) { switch (_that) {
case _SnPublisherHeatmap() when $default != null: case _SnPublisherHeatmap() when $default != null:
return $default(_that.unit,_that.periodStart,_that.periodEnd,_that.items);case _: 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 /// @nodoc
@JsonSerializable() @JsonSerializable()
class _SnPublisherHeatmap implements SnPublisherHeatmap { 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<SnPublisherHeatmapItem> items}): _items = items; 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); factory _SnPublisherHeatmap.fromJson(Map<String, dynamic> json) => _$SnPublisherHeatmapFromJson(json);
@override final String unit; @override final String unit;
@override@JsonKey(name: 'period_start') final DateTime periodStart; @override@JsonKey(name: 'period_start') final DateTime periodStart;
@override@JsonKey(name: 'period_end') final DateTime periodEnd; @override@JsonKey(name: 'period_end') final DateTime periodEnd;
final List<SnPublisherHeatmapItem> _items; final List<SnHeatmapItem> _items;
@override List<SnPublisherHeatmapItem> get items { @override List<SnHeatmapItem> get items {
if (_items is EqualUnmodifiableListView) return _items; if (_items is EqualUnmodifiableListView) return _items;
// ignore: implicit_dynamic_type // ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_items); return EqualUnmodifiableListView(_items);
@@ -253,7 +253,7 @@ abstract mixin class _$SnPublisherHeatmapCopyWith<$Res> implements $SnPublisherH
factory _$SnPublisherHeatmapCopyWith(_SnPublisherHeatmap value, $Res Function(_SnPublisherHeatmap) _then) = __$SnPublisherHeatmapCopyWithImpl; factory _$SnPublisherHeatmapCopyWith(_SnPublisherHeatmap value, $Res Function(_SnPublisherHeatmap) _then) = __$SnPublisherHeatmapCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $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 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,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 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. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline') @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. /// Serializes this SnPublisherHeatmapItem to a JSON map.
Map<String, dynamic> toJson(); Map<String, dynamic> toJson();
@@ -300,7 +300,7 @@ $SnPublisherHeatmapItemCopyWith<SnPublisherHeatmapItem> get copyWith => _$SnPubl
@override @override
bool operator ==(Object other) { 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) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -317,7 +317,7 @@ String toString() {
/// @nodoc /// @nodoc
abstract mixin class $SnPublisherHeatmapItemCopyWith<$Res> { abstract mixin class $SnPublisherHeatmapItemCopyWith<$Res> {
factory $SnPublisherHeatmapItemCopyWith(SnPublisherHeatmapItem value, $Res Function(SnPublisherHeatmapItem) _then) = _$SnPublisherHeatmapItemCopyWithImpl; factory $SnPublisherHeatmapItemCopyWith(SnHeatmapItem value, $Res Function(SnHeatmapItem) _then) = _$SnPublisherHeatmapItemCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
DateTime date, int count DateTime date, int count
@@ -332,8 +332,8 @@ class _$SnPublisherHeatmapItemCopyWithImpl<$Res>
implements $SnPublisherHeatmapItemCopyWith<$Res> { implements $SnPublisherHeatmapItemCopyWith<$Res> {
_$SnPublisherHeatmapItemCopyWithImpl(this._self, this._then); _$SnPublisherHeatmapItemCopyWithImpl(this._self, this._then);
final SnPublisherHeatmapItem _self; final SnHeatmapItem _self;
final $Res Function(SnPublisherHeatmapItem) _then; final $Res Function(SnHeatmapItem) _then;
/// Create a copy of SnPublisherHeatmapItem /// Create a copy of SnPublisherHeatmapItem
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@@ -348,8 +348,8 @@ as int,
} }
/// Adds pattern-matching-related methods to [SnPublisherHeatmapItem]. /// Adds pattern-matching-related methods to [SnHeatmapItem].
extension SnPublisherHeatmapItemPatterns on SnPublisherHeatmapItem { extension SnPublisherHeatmapItemPatterns on SnHeatmapItem {
/// A variant of `map` that fallback to returning `orElse`. /// A variant of `map` that fallback to returning `orElse`.
/// ///
/// It is equivalent to doing: /// It is equivalent to doing:
@@ -475,7 +475,7 @@ return $default(_that.date,_that.count);case _:
/// @nodoc /// @nodoc
@JsonSerializable() @JsonSerializable()
class _SnPublisherHeatmapItem implements SnPublisherHeatmapItem { class _SnPublisherHeatmapItem implements SnHeatmapItem {
const _SnPublisherHeatmapItem({required this.date, required this.count}); const _SnPublisherHeatmapItem({required this.date, required this.count});
factory _SnPublisherHeatmapItem.fromJson(Map<String, dynamic> json) => _$SnPublisherHeatmapItemFromJson(json); 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), periodEnd: DateTime.parse(json['period_end'] as String),
items: items:
(json['items'] as List<dynamic>) (json['items'] as List<dynamic>)
.map( .map((e) => SnHeatmapItem.fromJson(e as Map<String, dynamic>))
(e) =>
SnPublisherHeatmapItem.fromJson(e as Map<String, dynamic>),
)
.toList(), .toList(),
); );

View File

@@ -1,7 +1,6 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:fl_heatmap/fl_heatmap.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.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/cloud_files.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/response.dart'; import 'package:island/widgets/response.dart';
import 'package:island/widgets/activity_heatmap.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
@@ -36,11 +36,11 @@ Future<SnPublisherStats?> publisherStats(Ref ref, String? uname) async {
} }
@riverpod @riverpod
Future<SnPublisherHeatmap?> publisherHeatmap(Ref ref, String? uname) async { Future<SnHeatmap?> publisherHeatmap(Ref ref, String? uname) async {
if (uname == null) return null; if (uname == null) return null;
final apiClient = ref.watch(apiClientProvider); final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get('/sphere/publishers/$uname/heatmap'); final resp = await apiClient.get('/sphere/publishers/$uname/heatmap');
return SnPublisherHeatmap.fromJson(resp.data); return SnHeatmap.fromJson(resp.data);
} }
@riverpod @riverpod
@@ -601,7 +601,7 @@ class CreatorHubScreen extends HookConsumerWidget {
class _PublisherStatsWidget extends StatelessWidget { class _PublisherStatsWidget extends StatelessWidget {
final SnPublisherStats stats; final SnPublisherStats stats;
final SnPublisherHeatmap? heatmap; final SnHeatmap? heatmap;
const _PublisherStatsWidget({required this.stats, this.heatmap}); const _PublisherStatsWidget({required this.stats, this.heatmap});
@override @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(); const publisherHeatmapProvider = PublisherHeatmapFamily();
/// See also [publisherHeatmap]. /// See also [publisherHeatmap].
class PublisherHeatmapFamily extends Family<AsyncValue<SnPublisherHeatmap?>> { class PublisherHeatmapFamily extends Family<AsyncValue<SnHeatmap?>> {
/// See also [publisherHeatmap]. /// See also [publisherHeatmap].
const PublisherHeatmapFamily(); const PublisherHeatmapFamily();
@@ -188,8 +188,7 @@ class PublisherHeatmapFamily extends Family<AsyncValue<SnPublisherHeatmap?>> {
} }
/// See also [publisherHeatmap]. /// See also [publisherHeatmap].
class PublisherHeatmapProvider class PublisherHeatmapProvider extends AutoDisposeFutureProvider<SnHeatmap?> {
extends AutoDisposeFutureProvider<SnPublisherHeatmap?> {
/// See also [publisherHeatmap]. /// See also [publisherHeatmap].
PublisherHeatmapProvider(String? uname) PublisherHeatmapProvider(String? uname)
: this._internal( : this._internal(
@@ -220,7 +219,7 @@ class PublisherHeatmapProvider
@override @override
Override overrideWith( Override overrideWith(
FutureOr<SnPublisherHeatmap?> Function(PublisherHeatmapRef provider) create, FutureOr<SnHeatmap?> Function(PublisherHeatmapRef provider) create,
) { ) {
return ProviderOverride( return ProviderOverride(
origin: this, origin: this,
@@ -237,7 +236,7 @@ class PublisherHeatmapProvider
} }
@override @override
AutoDisposeFutureProviderElement<SnPublisherHeatmap?> createElement() { AutoDisposeFutureProviderElement<SnHeatmap?> createElement() {
return _PublisherHeatmapProviderElement(this); return _PublisherHeatmapProviderElement(this);
} }
@@ -257,13 +256,13 @@ class PublisherHeatmapProvider
@Deprecated('Will be removed in 3.0. Use Ref instead') @Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element // ignore: unused_element
mixin PublisherHeatmapRef on AutoDisposeFutureProviderRef<SnPublisherHeatmap?> { mixin PublisherHeatmapRef on AutoDisposeFutureProviderRef<SnHeatmap?> {
/// The parameter `uname` of this provider. /// The parameter `uname` of this provider.
String? get uname; String? get uname;
} }
class _PublisherHeatmapProviderElement class _PublisherHeatmapProviderElement
extends AutoDisposeFutureProviderElement<SnPublisherHeatmap?> extends AutoDisposeFutureProviderElement<SnHeatmap?>
with PublisherHeatmapRef { with PublisherHeatmapRef {
_PublisherHeatmapProviderElement(super.provider); _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
}
}
}