diff --git a/lib/screens/account/pfp.dart b/lib/screens/account/pfp.dart index e8ddc32..2e9be1d 100644 --- a/lib/screens/account/pfp.dart +++ b/lib/screens/account/pfp.dart @@ -1,6 +1,7 @@ import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; +import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; @@ -14,6 +15,7 @@ import 'package:surface/providers/relationship.dart'; import 'package:surface/providers/sn_network.dart'; import 'package:surface/screens/abuse_report.dart'; import 'package:surface/types/account.dart'; +import 'package:surface/types/check_in.dart'; import 'package:surface/types/post.dart'; import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/dialog.dart'; @@ -62,6 +64,19 @@ class _UserScreenState extends State with SingleTickerProviderStateM } } + Future> _getCheckInRecords() async { + try { + final sn = context.read(); + final resp = await sn.client.get('/cgi/id/users/${widget.name}/check-in?take=14'); + return List.from( + resp.data['data']?.map((x) => SnCheckInRecord.fromJson(x)) ?? [], + ); + } catch (err) { + if (mounted) context.showErrorDialog(err); + rethrow; + } + } + SnAccountStatusInfo? _status; Future _fetchStatus() async { @@ -497,6 +512,27 @@ class _UserScreenState extends State with SingleTickerProviderStateM ), SliverToBoxAdapter(child: const Divider()), const SliverGap(12), + SliverToBoxAdapter( + child: FutureBuilder>( + future: _getCheckInRecords(), + builder: (context, snapshot) { + if (!snapshot.hasData) return const SizedBox.shrink(); + final records = snapshot.data!; + return SizedBox( + width: double.infinity, + height: 240, + child: CheckInRecordChart(records: records), + ).padding( + right: 24, + left: 16, + top: 12, + ); + }, + ), + ), + const SliverGap(12), + SliverToBoxAdapter(child: const Divider()), + const SliverGap(12), SliverToBoxAdapter( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -565,3 +601,105 @@ class _UserScreenState extends State with SingleTickerProviderStateM ); } } + +class CheckInRecordChart extends StatelessWidget { + const CheckInRecordChart({ + super.key, + required this.records, + }); + + final List records; + + @override + Widget build(BuildContext context) { + return LineChart( + LineChartData( + lineBarsData: [ + LineChartBarData( + color: Theme.of(context).colorScheme.primary, + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + colors: List.filled( + records.length, + Theme.of(context).colorScheme.primary.withOpacity(0.3), + ).toList(), + ), + ), + spots: records + .map( + (x) => FlSpot( + x.createdAt + .copyWith( + hour: 0, + minute: 0, + second: 0, + millisecond: 0, + microsecond: 0, + ) + .millisecondsSinceEpoch + .toDouble(), + x.resultTier.toDouble(), + ), + ) + .toList(), + ) + ], + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + getTooltipItems: (spots) => spots + .map( + (spot) => LineTooltipItem( + '${kCheckInResultTierSymbols[spot.y.toInt()]}\n${DateFormat('MM/dd').format(DateTime.fromMillisecondsSinceEpoch(spot.x.toInt()))}', + TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ) + .toList(), + getTooltipColor: (_) => Theme.of(context).colorScheme.surfaceContainerHigh, + ), + ), + titlesData: FlTitlesData( + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + interval: 1, + getTitlesWidget: (value, _) => Align( + alignment: Alignment.centerRight, + child: Text( + kCheckInResultTierSymbols[value.toInt()], + textAlign: TextAlign.right, + ).padding(right: 8), + ), + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + interval: 86400000, + getTitlesWidget: (value, _) => Text( + DateFormat('dd').format( + DateTime.fromMillisecondsSinceEpoch( + value.toInt(), + ), + ), + textAlign: TextAlign.center, + ).padding(top: 8), + ), + ), + ), + gridData: const FlGridData(show: false), + borderData: FlBorderData(show: false), + ), + ); + } +} diff --git a/lib/types/check_in.dart b/lib/types/check_in.dart index 7d3033a..3704c75 100644 --- a/lib/types/check_in.dart +++ b/lib/types/check_in.dart @@ -3,6 +3,8 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'check_in.freezed.dart'; part 'check_in.g.dart'; +const List kCheckInResultTierSymbols = ['大凶', '凶', '中平', '吉', '大吉']; + @freezed class SnCheckInRecord with _$SnCheckInRecord { const SnCheckInRecord._(); @@ -21,11 +23,5 @@ class SnCheckInRecord with _$SnCheckInRecord { factory SnCheckInRecord.fromJson(Map json) => _$SnCheckInRecordFromJson(json); - String get symbol => switch (resultTier) { - 0 => '大凶', - 1 => '凶', - 2 => '中平', - 3 => '吉', - _ => '大吉', - }; + String get symbol => kCheckInResultTierSymbols[resultTier]; } diff --git a/pubspec.lock b/pubspec.lock index 7403616..6da9693 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -614,6 +614,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: c724234b05e378383e958f3e82ca84a3e1e3c06a0898462044dd8a24b1ee9864 + url: "https://pub.dev" + source: hosted + version: "0.70.0" flutter: dependency: "direct main" description: flutter @@ -2145,4 +2153,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.6.0 <4.0.0" - flutter: ">=3.24.0" + flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index 08f6a74..4117b8f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -112,6 +112,7 @@ dependencies: in_app_review: ^2.0.10 version: ^3.0.2 flutter_colorpicker: ^1.1.0 + fl_chart: ^0.70.0 dev_dependencies: flutter_test: