Add stats overview

This commit is contained in:
problematicconsumer
2023-08-30 16:18:38 +03:30
parent 89bc1b80fd
commit 6a2e359bbe
15 changed files with 192 additions and 208 deletions

View File

@@ -18,6 +18,12 @@
"connecting": "connecting", "connecting": "connecting",
"disconnecting": "disconnecting", "disconnecting": "disconnecting",
"connected": "connected" "connected": "connected"
},
"stats": {
"traffic": "traffic",
"trafficTotal": "traffic total",
"uplink": "uplink",
"downlink": "downlink"
} }
}, },
"profile": { "profile": {

View File

@@ -18,6 +18,12 @@
"connecting": "در حال اتصال", "connecting": "در حال اتصال",
"disconnecting": "در حال قطع اتصال", "disconnecting": "در حال قطع اتصال",
"connected": "متصل" "connected": "متصل"
},
"stats": {
"traffic": "ترافیک",
"trafficTotal": "مجموع ترافیک",
"uplink": "ارسال",
"downlink": "دریافت"
} }
}, },
"profile": { "profile": {

View File

@@ -1,4 +1,3 @@
export 'connection_facade.dart'; export 'connection_facade.dart';
export 'connection_failure.dart'; export 'connection_failure.dart';
export 'connection_status.dart'; export 'connection_status.dart';
export 'traffic.dart';

View File

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

View File

@@ -5,6 +5,8 @@ part 'core_status.g.dart';
@freezed @freezed
class CoreStatus with _$CoreStatus { class CoreStatus with _$CoreStatus {
const CoreStatus._();
@JsonSerializable(fieldRename: FieldRename.kebab) @JsonSerializable(fieldRename: FieldRename.kebab)
const factory CoreStatus({ const factory CoreStatus({
required int connectionsIn, required int connectionsIn,
@@ -15,6 +17,15 @@ class CoreStatus with _$CoreStatus {
required int downlinkTotal, required int downlinkTotal,
}) = _CoreStatus; }) = _CoreStatus;
factory CoreStatus.empty() => const CoreStatus(
connectionsIn: 0,
connectionsOut: 0,
uplink: 0,
downlink: 0,
uplinkTotal: 0,
downlinkTotal: 0,
);
factory CoreStatus.fromJson(Map<String, dynamic> json) => factory CoreStatus.fromJson(Map<String, dynamic> json) =>
_$CoreStatusFromJson(json); _$CoreStatusFromJson(json);
} }

View File

@@ -1,3 +1,4 @@
export 'core_status.dart'; export 'core_status.dart';
export 'outbounds.dart'; export 'outbounds.dart';
export 'proxy_type.dart';
export 'singbox_facade.dart'; export 'singbox_facade.dart';

View File

@@ -299,16 +299,8 @@ class ProfileSubscriptionInfo extends HookConsumerWidget {
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text.rich( Text(
TextSpan( subInfo.consumption.sizeOf(subInfo.total),
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),
],
),
style: theme.textTheme.bodySmall, style: theme.textTheme.bodySmall,
), ),
Text( Text(

View File

@@ -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<CoreStatus> 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());
}
}
}

View File

@@ -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),
],
),
],
),
),
);
}
}

View File

@@ -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<Traffic> 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),
],
);
}
}

View File

@@ -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<List<Traffic>> 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<Traffic> _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),
];
}
}

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hiddify/core/core_providers.dart'; import 'package:hiddify/core/core_providers.dart';
import 'package:hiddify/core/router/router.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:hooks_riverpod/hooks_riverpod.dart';
import 'package:recase/recase.dart'; import 'package:recase/recase.dart';
@@ -53,7 +53,7 @@ class DesktopWrapper extends HookConsumerWidget {
trailing: const Expanded( trailing: const Expanded(
child: Align( child: Align(
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
child: TrafficChart(), child: StatsOverview(),
), ),
), ),
), ),

View File

@@ -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}) { String sizeOf(int total) =>
const base = 1024; "${_sizeOfFormat.format(bytes())} / ${_sizeOfFormat.format(total.bytes())}";
if (input <= 0) return (size: "0", unit: _units[unit ?? 0]);
final int digitGroups = unit ?? (log(input) / log(base)).round(); static final _rateFormat =
return ( InformationRateFormat(permissibleRateUnits: {RateUnit.second});
size: NumberFormat("#,##0.#").format(input / pow(base, digitGroups)),
unit: _units[digitGroups], String speed() => _rateFormat.format(bytes().per(const Duration(seconds: 1)));
);
}
// 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",
);
} }

View File

@@ -297,6 +297,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.8" version: "0.7.8"
decimal:
dependency: transitive
description:
name: decimal
sha256: "24a261d5d5c87e86c7651c417a5dbdf8bcd7080dd592533910e8d0505a279f21"
url: "https://pub.dev"
source: hosted
version: "2.3.3"
dio: dio:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -645,6 +653,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.2" 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: icons_launcher:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -957,6 +973,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.1" version: "3.2.1"
rational:
dependency: transitive
description:
name: rational
sha256: ba58e9e18df9abde280e8b10051e4bce85091e41e8e7e411b6cde2e738d357cf
url: "https://pub.dev"
source: hosted
version: "2.2.2"
recase: recase:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@@ -76,6 +76,7 @@ dependencies:
sliver_tools: ^0.2.12 sliver_tools: ^0.2.12
flutter_adaptive_scaffold: ^0.1.6 flutter_adaptive_scaffold: ^0.1.6
fl_chart: ^0.63.0 fl_chart: ^0.63.0
humanizer: ^2.2.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: