♻️ 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

@@ -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);