Add stats overview
This commit is contained in:
@@ -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": {
|
||||||
|
|||||||
@@ -18,6 +18,12 @@
|
|||||||
"connecting": "در حال اتصال",
|
"connecting": "در حال اتصال",
|
||||||
"disconnecting": "در حال قطع اتصال",
|
"disconnecting": "در حال قطع اتصال",
|
||||||
"connected": "متصل"
|
"connected": "متصل"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"traffic": "ترافیک",
|
||||||
|
"trafficTotal": "مجموع ترافیک",
|
||||||
|
"uplink": "ارسال",
|
||||||
|
"downlink": "دریافت"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
|
|||||||
@@ -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';
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
23
lib/features/common/stats/stats_notifier.dart
Normal file
23
lib/features/common/stats/stats_notifier.dart
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
104
lib/features/common/stats/stats_overview.dart
Normal file
104
lib/features/common/stats/stats_overview.dart
Normal 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
24
pubspec.lock
24
pubspec.lock
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user