Heatmap

This commit is contained in:
2025-10-12 18:47:01 +08:00
parent 5327f04ec0
commit 6124dbfd79
6 changed files with 854 additions and 9 deletions

View File

@@ -1,6 +1,7 @@
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';
@@ -8,6 +9,7 @@ import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post.dart';
import 'package:island/models/publisher.dart';
import 'package:island/models/heatmap.dart';
import 'package:island/pods/network.dart';
import 'package:island/screens/creators/publishers_form.dart';
import 'package:island/services/responsive.dart';
@@ -33,6 +35,14 @@ Future<SnPublisherStats?> publisherStats(Ref ref, String? uname) async {
return SnPublisherStats.fromJson(resp.data);
}
@riverpod
Future<SnPublisherHeatmap?> 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);
}
@riverpod
Future<SnPublisherMember?> publisherIdentity(Ref ref, String uname) async {
try {
@@ -329,6 +339,10 @@ class CreatorHubScreen extends HookConsumerWidget {
publisherStatsProvider(currentPublisher.value?.name),
);
final publisherHeatmap = ref.watch(
publisherHeatmapProvider(currentPublisher.value?.name),
);
final publisherFeatures = ref.watch(
publisherFeaturesProvider(currentPublisher.value?.name),
);
@@ -557,6 +571,7 @@ class CreatorHubScreen extends HookConsumerWidget {
if (stats != null)
_PublisherStatsWidget(
stats: stats,
heatmap: publisherHeatmap.value,
).padding(horizontal: 12),
buildNavigationWidget(true),
],
@@ -567,6 +582,7 @@ class CreatorHubScreen extends HookConsumerWidget {
if (stats != null)
_PublisherStatsWidget(
stats: stats,
heatmap: publisherHeatmap.value,
).padding(horizontal: 16),
buildNavigationWidget(false),
],
@@ -585,7 +601,8 @@ class CreatorHubScreen extends HookConsumerWidget {
class _PublisherStatsWidget extends StatelessWidget {
final SnPublisherStats stats;
const _PublisherStatsWidget({required this.stats});
final SnPublisherHeatmap? heatmap;
const _PublisherStatsWidget({required this.stats, this.heatmap});
@override
Widget build(BuildContext context) {
@@ -638,6 +655,7 @@ class _PublisherStatsWidget extends StatelessWidget {
),
],
),
if (heatmap != null) _PublisherHeatmapWidget(heatmap: heatmap!),
],
),
);
@@ -1179,3 +1197,97 @@ class _PublisherInviteSheet extends HookConsumerWidget {
);
}
}
class _PublisherHeatmapWidget extends StatelessWidget {
final SnPublisherHeatmap heatmap;
const _PublisherHeatmapWidget({required this.heatmap});
@override
Widget build(BuildContext context) {
// Find min and max dates
final dates = heatmap.items.map((e) => e.date).toList();
if (dates.isEmpty) return const SizedBox.shrink();
final minDate = dates.reduce((a, b) => a.isBefore(b) ? a : b);
final maxDate = dates.reduce((a, b) => a.isAfter(b) ? a : b);
// Find monday of the week containing minDate
final startMonday = minDate.subtract(Duration(days: minDate.weekday - 1));
// Find sunday of the week containing maxDate
final endSunday = maxDate.add(Duration(days: 7 - maxDate.weekday));
// Generate all weeks
final weeks = <DateTime>[];
var current = startMonday;
while (current.isBefore(endSunday) || current.isAtSameMomentAs(endSunday)) {
weeks.add(current);
current = current.add(const Duration(days: 7));
}
// Columns: Mon to Sun
const columns = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
// Create data map
final dataMap = <String, Map<String, double>>{};
for (final week in weeks) {
final weekKey =
'${week.year}-${week.month.toString().padLeft(2, '0')}-${week.day.toString().padLeft(2, '0')}';
dataMap[weekKey] = {};
for (var i = 0; i < 7; i++) {
final date = week.add(Duration(days: i));
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[weekKey]![columns[i]] = item.count.toDouble();
}
}
final heatmapData = HeatmapData(
rows:
weeks
.map(
(w) =>
'${w.year}-${w.month.toString().padLeft(2, '0')}-${w.day.toString().padLeft(2, '0')}',
)
.toList(),
columns: columns,
items: [
for (final row in dataMap.entries)
for (final col in row.value.entries)
HeatmapItem(
value: col.value,
unit: heatmap.unit,
xAxisLabel: col.key,
yAxisLabel: row.key,
),
],
);
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Activity Heatmap',
style: Theme.of(context).textTheme.titleMedium,
),
const Gap(8),
Heatmap(
showXAxisLabels: false,
showYAxisLabels: false,
heatmapData: heatmapData,
rowsVisible: 5,
),
],
),
),
);
}
}

View File

@@ -149,6 +149,128 @@ class _PublisherStatsProviderElement
String? get uname => (origin as PublisherStatsProvider).uname;
}
String _$publisherHeatmapHash() => r'780dfb05b8610a37cfcd937fd04cf5bbe9b298c9';
/// See also [publisherHeatmap].
@ProviderFor(publisherHeatmap)
const publisherHeatmapProvider = PublisherHeatmapFamily();
/// See also [publisherHeatmap].
class PublisherHeatmapFamily extends Family<AsyncValue<SnPublisherHeatmap?>> {
/// See also [publisherHeatmap].
const PublisherHeatmapFamily();
/// See also [publisherHeatmap].
PublisherHeatmapProvider call(String? uname) {
return PublisherHeatmapProvider(uname);
}
@override
PublisherHeatmapProvider getProviderOverride(
covariant PublisherHeatmapProvider provider,
) {
return call(provider.uname);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'publisherHeatmapProvider';
}
/// See also [publisherHeatmap].
class PublisherHeatmapProvider
extends AutoDisposeFutureProvider<SnPublisherHeatmap?> {
/// See also [publisherHeatmap].
PublisherHeatmapProvider(String? uname)
: this._internal(
(ref) => publisherHeatmap(ref as PublisherHeatmapRef, uname),
from: publisherHeatmapProvider,
name: r'publisherHeatmapProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$publisherHeatmapHash,
dependencies: PublisherHeatmapFamily._dependencies,
allTransitiveDependencies:
PublisherHeatmapFamily._allTransitiveDependencies,
uname: uname,
);
PublisherHeatmapProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.uname,
}) : super.internal();
final String? uname;
@override
Override overrideWith(
FutureOr<SnPublisherHeatmap?> Function(PublisherHeatmapRef provider) create,
) {
return ProviderOverride(
origin: this,
override: PublisherHeatmapProvider._internal(
(ref) => create(ref as PublisherHeatmapRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
uname: uname,
),
);
}
@override
AutoDisposeFutureProviderElement<SnPublisherHeatmap?> createElement() {
return _PublisherHeatmapProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is PublisherHeatmapProvider && other.uname == uname;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, uname.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin PublisherHeatmapRef on AutoDisposeFutureProviderRef<SnPublisherHeatmap?> {
/// The parameter `uname` of this provider.
String? get uname;
}
class _PublisherHeatmapProviderElement
extends AutoDisposeFutureProviderElement<SnPublisherHeatmap?>
with PublisherHeatmapRef {
_PublisherHeatmapProviderElement(super.provider);
@override
String? get uname => (origin as PublisherHeatmapProvider).uname;
}
String _$publisherIdentityHash() => r'299372f25fa4b2bf8e11a8ba2d645100fc38e76f';
/// See also [publisherIdentity].