From 6a2e359bbe59bb5880de3a8b2816c91713ad3f92 Mon Sep 17 00:00:00 2001 From: problematicconsumer Date: Wed, 30 Aug 2023 16:18:38 +0330 Subject: [PATCH] Add stats overview --- assets/translations/strings.i18n.json | 6 + assets/translations/strings_fa.i18n.json | 6 + lib/domain/connectivity/connectivity.dart | 1 - lib/domain/connectivity/traffic.dart | 13 --- lib/domain/singbox/core_status.dart | 11 ++ lib/domain/singbox/singbox.dart | 1 + lib/features/common/profile_tile.dart | 12 +- lib/features/common/stats/stats_notifier.dart | 23 ++++ lib/features/common/stats/stats_overview.dart | 104 +++++++++++++++++ .../common/traffic/traffic_chart.dart | 106 ------------------ .../common/traffic/traffic_notifier.dart | 54 --------- .../wrapper/view/desktop_wrapper.dart | 4 +- lib/utils/number_formatters.dart | 34 ++---- pubspec.lock | 24 ++++ pubspec.yaml | 1 + 15 files changed, 192 insertions(+), 208 deletions(-) delete mode 100644 lib/domain/connectivity/traffic.dart create mode 100644 lib/features/common/stats/stats_notifier.dart create mode 100644 lib/features/common/stats/stats_overview.dart delete mode 100644 lib/features/common/traffic/traffic_chart.dart delete mode 100644 lib/features/common/traffic/traffic_notifier.dart diff --git a/assets/translations/strings.i18n.json b/assets/translations/strings.i18n.json index 67fbcb55..16f0fb0a 100644 --- a/assets/translations/strings.i18n.json +++ b/assets/translations/strings.i18n.json @@ -18,6 +18,12 @@ "connecting": "connecting", "disconnecting": "disconnecting", "connected": "connected" + }, + "stats": { + "traffic": "traffic", + "trafficTotal": "traffic total", + "uplink": "uplink", + "downlink": "downlink" } }, "profile": { diff --git a/assets/translations/strings_fa.i18n.json b/assets/translations/strings_fa.i18n.json index 6a14c357..4d9f3282 100644 --- a/assets/translations/strings_fa.i18n.json +++ b/assets/translations/strings_fa.i18n.json @@ -18,6 +18,12 @@ "connecting": "در حال اتصال", "disconnecting": "در حال قطع اتصال", "connected": "متصل" + }, + "stats": { + "traffic": "ترافیک", + "trafficTotal": "مجموع ترافیک", + "uplink": "ارسال", + "downlink": "دریافت" } }, "profile": { diff --git a/lib/domain/connectivity/connectivity.dart b/lib/domain/connectivity/connectivity.dart index 486c9e4f..c161cf81 100644 --- a/lib/domain/connectivity/connectivity.dart +++ b/lib/domain/connectivity/connectivity.dart @@ -1,4 +1,3 @@ export 'connection_facade.dart'; export 'connection_failure.dart'; export 'connection_status.dart'; -export 'traffic.dart'; diff --git a/lib/domain/connectivity/traffic.dart b/lib/domain/connectivity/traffic.dart deleted file mode 100644 index a7053d53..00000000 --- a/lib/domain/connectivity/traffic.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'traffic.freezed.dart'; - -@freezed -class Traffic with _$Traffic { - const Traffic._(); - - const factory Traffic({ - required int upload, - required int download, - }) = _Traffic; -} diff --git a/lib/domain/singbox/core_status.dart b/lib/domain/singbox/core_status.dart index b757e4ee..979e57df 100644 --- a/lib/domain/singbox/core_status.dart +++ b/lib/domain/singbox/core_status.dart @@ -5,6 +5,8 @@ part 'core_status.g.dart'; @freezed class CoreStatus with _$CoreStatus { + const CoreStatus._(); + @JsonSerializable(fieldRename: FieldRename.kebab) const factory CoreStatus({ required int connectionsIn, @@ -15,6 +17,15 @@ class CoreStatus with _$CoreStatus { required int downlinkTotal, }) = _CoreStatus; + factory CoreStatus.empty() => const CoreStatus( + connectionsIn: 0, + connectionsOut: 0, + uplink: 0, + downlink: 0, + uplinkTotal: 0, + downlinkTotal: 0, + ); + factory CoreStatus.fromJson(Map json) => _$CoreStatusFromJson(json); } diff --git a/lib/domain/singbox/singbox.dart b/lib/domain/singbox/singbox.dart index af607280..1f8d1d20 100644 --- a/lib/domain/singbox/singbox.dart +++ b/lib/domain/singbox/singbox.dart @@ -1,3 +1,4 @@ export 'core_status.dart'; export 'outbounds.dart'; +export 'proxy_type.dart'; export 'singbox_facade.dart'; diff --git a/lib/features/common/profile_tile.dart b/lib/features/common/profile_tile.dart index 70c6b038..9999ae57 100644 --- a/lib/features/common/profile_tile.dart +++ b/lib/features/common/profile_tile.dart @@ -299,16 +299,8 @@ class ProfileSubscriptionInfo extends HookConsumerWidget { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text.rich( - TextSpan( - children: [ - TextSpan(text: formatByte(subInfo.consumption, unit: 3).size), - const TextSpan(text: " / "), - TextSpan(text: formatByte(subInfo.total, unit: 3).size), - const TextSpan(text: " "), - TextSpan(text: t.profile.subscription.gigaByte), - ], - ), + Text( + subInfo.consumption.sizeOf(subInfo.total), style: theme.textTheme.bodySmall, ), Text( diff --git a/lib/features/common/stats/stats_notifier.dart b/lib/features/common/stats/stats_notifier.dart new file mode 100644 index 00000000..5a1c0ecb --- /dev/null +++ b/lib/features/common/stats/stats_notifier.dart @@ -0,0 +1,23 @@ +import 'package:hiddify/data/data_providers.dart'; +import 'package:hiddify/domain/singbox/singbox.dart'; +import 'package:hiddify/features/common/connectivity/connectivity_controller.dart'; +import 'package:hiddify/utils/utils.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'stats_notifier.g.dart'; + +@riverpod +class StatsNotifier extends _$StatsNotifier with AppLogger { + @override + Stream build() async* { + final serviceRunning = await ref.watch(serviceRunningProvider.future); + if (serviceRunning) { + yield* ref + .watch(coreFacadeProvider) + .watchCoreStatus() + .map((event) => event.getOrElse((_) => CoreStatus.empty())); + } else { + yield* Stream.value(CoreStatus.empty()); + } + } +} diff --git a/lib/features/common/stats/stats_overview.dart b/lib/features/common/stats/stats_overview.dart new file mode 100644 index 00000000..24a80339 --- /dev/null +++ b/lib/features/common/stats/stats_overview.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hiddify/core/core_providers.dart'; +import 'package:hiddify/domain/singbox/singbox.dart'; +import 'package:hiddify/features/common/stats/stats_notifier.dart'; +import 'package:hiddify/utils/utils.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:recase/recase.dart'; + +class StatsOverview extends HookConsumerWidget { + const StatsOverview({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final t = ref.watch(translationsProvider); + + final stats = + ref.watch(statsNotifierProvider).asData?.value ?? CoreStatus.empty(); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _StatCard( + title: t.home.stats.traffic.titleCase, + firstStat: ( + label: t.home.stats.uplink.titleCase, + data: stats.uplink.speed(), + ), + secondStat: ( + label: t.home.stats.downlink.titleCase, + data: stats.downlink.speed(), + ), + ), + const Gap(8), + _StatCard( + title: t.home.stats.trafficTotal.titleCase, + firstStat: ( + label: t.home.stats.uplink.titleCase, + data: stats.uplinkTotal.size(), + ), + secondStat: ( + label: t.home.stats.downlink.titleCase, + data: stats.downlinkTotal.size(), + ), + ), + ], + ), + ); + } +} + +class _StatCard extends HookConsumerWidget { + const _StatCard({ + required this.title, + required this.firstStat, + required this.secondStat, + }); + + final String title; + final ({String label, String data}) firstStat; + final ({String label, String data}) secondStat; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + return Card( + margin: EdgeInsets.zero, + shadowColor: Colors.transparent, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title), + const Gap(4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + firstStat.label, + style: theme.textTheme.bodySmall, + ), + Text(firstStat.data), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + secondStat.label, + style: theme.textTheme.bodySmall, + ), + Text(secondStat.data), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/common/traffic/traffic_chart.dart b/lib/features/common/traffic/traffic_chart.dart deleted file mode 100644 index b7fd0b16..00000000 --- a/lib/features/common/traffic/traffic_chart.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'package:dartx/dartx.dart'; -import 'package:fl_chart/fl_chart.dart'; -import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; -import 'package:hiddify/domain/connectivity/connectivity.dart'; -import 'package:hiddify/features/common/traffic/traffic_notifier.dart'; -import 'package:hiddify/utils/utils.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -// TODO: test implementation, rewrite -class TrafficChart extends HookConsumerWidget { - const TrafficChart({ - super.key, - this.chartSteps = 20, - }); - - final int chartSteps; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final asyncTraffics = ref.watch(trafficNotifierProvider); - - switch (asyncTraffics) { - case AsyncData(value: final traffics): - return _Chart(traffics, chartSteps); - case AsyncLoading(:final value): - if (value == null) return const SizedBox(); - return _Chart(value, chartSteps); - default: - return const SizedBox(); - } - } -} - -class _Chart extends StatelessWidget { - const _Chart(this.records, this.steps); - - final List records; - final int steps; - - @override - Widget build(BuildContext context) { - final latest = records.lastOrNull ?? const Traffic(upload: 0, download: 0); - final latestUploadData = formatByteSpeed(latest.upload); - final latestDownloadData = formatByteSpeed(latest.download); - - final uploadChartSpots = records.takeLast(steps).mapIndexed( - (index, p) => FlSpot(index.toDouble(), p.upload.toDouble()), - ); - final downloadChartSpots = records.takeLast(steps).mapIndexed( - (index, p) => FlSpot(index.toDouble(), p.download.toDouble()), - ); - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - height: 68, - child: LineChart( - LineChartData( - minY: 0, - borderData: FlBorderData(show: false), - titlesData: const FlTitlesData(show: false), - gridData: const FlGridData(show: false), - lineTouchData: const LineTouchData(enabled: false), - lineBarsData: [ - LineChartBarData( - isCurved: true, - preventCurveOverShooting: true, - dotData: const FlDotData(show: false), - spots: uploadChartSpots.toList(), - ), - LineChartBarData( - color: Theme.of(context).colorScheme.tertiary, - isCurved: true, - preventCurveOverShooting: true, - dotData: const FlDotData(show: false), - spots: downloadChartSpots.toList(), - ), - ], - ), - duration: Duration.zero, - ), - ), - const Gap(16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - const Text("↑"), - Text(latestUploadData.size), - Text(latestUploadData.unit), - ], - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - const Text("↓"), - Text(latestDownloadData.size), - Text(latestDownloadData.unit), - ], - ), - const Gap(16), - ], - ); - } -} diff --git a/lib/features/common/traffic/traffic_notifier.dart b/lib/features/common/traffic/traffic_notifier.dart deleted file mode 100644 index 75b64ed7..00000000 --- a/lib/features/common/traffic/traffic_notifier.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:dartx/dartx.dart'; -import 'package:hiddify/data/data_providers.dart'; -import 'package:hiddify/domain/clash/clash.dart'; -import 'package:hiddify/domain/connectivity/connectivity.dart'; -import 'package:hiddify/features/common/connectivity/connectivity_controller.dart'; -import 'package:hiddify/utils/utils.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'traffic_notifier.g.dart'; - -// TODO: improve -@riverpod -class TrafficNotifier extends _$TrafficNotifier with AppLogger { - int get _steps => 100; - - @override - Stream> build() async* { - final serviceRunning = await ref.watch(serviceRunningProvider.future); - if (serviceRunning) { - // TODO: temporary! - yield* ref.watch(coreFacadeProvider).watchCoreStatus().map((event) { - return event.map( - (a) => ClashTraffic(upload: a.uplink, download: a.downlink), - ); - }).map( - (event) => _mapToState( - event.getOrElse((_) => const ClashTraffic(upload: 0, download: 0)), - ), - ); - } else { - yield* Stream.periodic(const Duration(seconds: 1)).asyncMap( - (_) async { - return const ClashTraffic(upload: 0, download: 0); - }, - ).map(_mapToState); - } - } - - List _mapToState(ClashTraffic event) { - final previous = state.valueOrNull ?? - List.generate( - _steps, - (index) => const Traffic(upload: 0, download: 0), - ); - while (previous.length < _steps) { - loggy.debug("previous short, adding"); - previous.insert(0, const Traffic(upload: 0, download: 0)); - } - return [ - ...previous.takeLast(_steps - 1), - Traffic(upload: event.upload, download: event.download), - ]; - } -} diff --git a/lib/features/wrapper/view/desktop_wrapper.dart b/lib/features/wrapper/view/desktop_wrapper.dart index bf2ccc69..9d9a5f3d 100644 --- a/lib/features/wrapper/view/desktop_wrapper.dart +++ b/lib/features/wrapper/view/desktop_wrapper.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hiddify/core/core_providers.dart'; import 'package:hiddify/core/router/router.dart'; -import 'package:hiddify/features/common/traffic/traffic_chart.dart'; +import 'package:hiddify/features/common/stats/stats_overview.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:recase/recase.dart'; @@ -53,7 +53,7 @@ class DesktopWrapper extends HookConsumerWidget { trailing: const Expanded( child: Align( alignment: Alignment.bottomCenter, - child: TrafficChart(), + child: StatsOverview(), ), ), ), diff --git a/lib/utils/number_formatters.dart b/lib/utils/number_formatters.dart index 15f54915..df1688dd 100644 --- a/lib/utils/number_formatters.dart +++ b/lib/utils/number_formatters.dart @@ -1,26 +1,16 @@ -import 'dart:math'; +import 'package:humanizer/humanizer.dart'; -import 'package:intl/intl.dart'; +extension ByteFormatter on int { + String size() => bytes().toString(); -const _units = ["B", "kB", "MB", "GB", "TB"]; + static final _sizeOfFormat = + InformationSizeFormat(permissibleValueUnits: {InformationUnit.gibibyte}); -({String size, String unit}) formatByte(int input, {int? unit}) { - const base = 1024; - if (input <= 0) return (size: "0", unit: _units[unit ?? 0]); - final int digitGroups = unit ?? (log(input) / log(base)).round(); - return ( - size: NumberFormat("#,##0.#").format(input / pow(base, digitGroups)), - unit: _units[digitGroups], - ); -} - -// TODO remove -({String size, String unit}) formatByteSpeed(int speed) { - const base = 1024; - if (speed <= 0) return (size: "0", unit: "B/s"); - final int digitGroups = (log(speed) / log(base)).round(); - return ( - size: NumberFormat("#,##0.#").format(speed / pow(base, digitGroups)), - unit: "${_units[digitGroups]}/s", - ); + String sizeOf(int total) => + "${_sizeOfFormat.format(bytes())} / ${_sizeOfFormat.format(total.bytes())}"; + + static final _rateFormat = + InformationRateFormat(permissibleRateUnits: {RateUnit.second}); + + String speed() => _rateFormat.format(bytes().per(const Duration(seconds: 1))); } diff --git a/pubspec.lock b/pubspec.lock index 88941212..18f7f1ee 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -297,6 +297,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.8" + decimal: + dependency: transitive + description: + name: decimal + sha256: "24a261d5d5c87e86c7651c417a5dbdf8bcd7080dd592533910e8d0505a279f21" + url: "https://pub.dev" + source: hosted + version: "2.3.3" dio: dependency: "direct main" description: @@ -645,6 +653,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + humanizer: + dependency: "direct main" + description: + name: humanizer + sha256: "08728a4b6d62accd7d09e668bd54e81e6e09a82c8cfda30553224b3eb868d4f2" + url: "https://pub.dev" + source: hosted + version: "2.2.0" icons_launcher: dependency: "direct dev" description: @@ -957,6 +973,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.1" + rational: + dependency: transitive + description: + name: rational + sha256: ba58e9e18df9abde280e8b10051e4bce85091e41e8e7e411b6cde2e738d357cf + url: "https://pub.dev" + source: hosted + version: "2.2.2" recase: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index b8a3d6e7..82f87c7e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -76,6 +76,7 @@ dependencies: sliver_tools: ^0.2.12 flutter_adaptive_scaffold: ^0.1.6 fl_chart: ^0.63.0 + humanizer: ^2.2.0 dev_dependencies: flutter_test: