initial
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'active_profile_notifier.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class ActiveProfile extends _$ActiveProfile with AppLogger {
|
||||
@override
|
||||
Stream<Profile?> build() {
|
||||
return ref
|
||||
.watch(profilesRepositoryProvider)
|
||||
.watchActiveProfile()
|
||||
.map((event) => event.getOrElse((l) => throw l));
|
||||
}
|
||||
|
||||
Future<Unit?> updateProfile() async {
|
||||
if (state case AsyncData(value: final profile?)) {
|
||||
loggy.debug("updating active profile");
|
||||
return ref
|
||||
.read(profilesRepositoryProvider)
|
||||
.update(profile)
|
||||
.getOrElse((l) => throw l)
|
||||
.run();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'has_any_profile_notifier.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Stream<bool> hasAnyProfile(
|
||||
HasAnyProfileRef ref,
|
||||
) {
|
||||
return ref
|
||||
.watch(profilesRepositoryProvider)
|
||||
.watchHasAnyProfile()
|
||||
.map((event) => event.getOrElse((l) => throw l));
|
||||
}
|
||||
202
lib/features/common/add_profile_modal.dart
Normal file
202
lib/features/common/add_profile_modal.dart
Normal file
@@ -0,0 +1,202 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/core/router/router.dart';
|
||||
import 'package:hiddify/features/common/qr_code_scanner_screen.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:recase/recase.dart';
|
||||
|
||||
class AddProfileModal extends HookConsumerWidget {
|
||||
const AddProfileModal({
|
||||
super.key,
|
||||
this.scrollController,
|
||||
});
|
||||
|
||||
final ScrollController? scrollController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
const buttonsPadding = 24.0;
|
||||
const buttonsGap = 16.0;
|
||||
|
||||
return SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
child: Column(
|
||||
children: [
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// temporary solution, aspect ratio widget relies on height and in a row there no height!
|
||||
final buttonWidth = constraints.maxWidth / 2 -
|
||||
(buttonsPadding + (buttonsGap / 2));
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: buttonsPadding),
|
||||
child: Row(
|
||||
children: [
|
||||
_Button(
|
||||
label: t.profile.add.fromClipboard.sentenceCase,
|
||||
icon: Icons.content_paste,
|
||||
size: buttonWidth,
|
||||
onTap: () async {
|
||||
final captureResult =
|
||||
await Clipboard.getData(Clipboard.kTextPlain);
|
||||
final link =
|
||||
LinkParser.simple(captureResult?.text ?? '');
|
||||
if (link != null && context.mounted) {
|
||||
context.pop();
|
||||
await NewProfileRoute(url: link.url, name: link.name)
|
||||
.push(context);
|
||||
} else {
|
||||
CustomToast.error(
|
||||
t.profile.add.invalidUrlMsg.sentenceCase,
|
||||
).show(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
const Gap(buttonsGap),
|
||||
if (!PlatformUtils.isDesktop)
|
||||
_Button(
|
||||
label: t.profile.add.scanQr,
|
||||
icon: Icons.qr_code_scanner,
|
||||
size: buttonWidth,
|
||||
onTap: () async {
|
||||
final captureResult =
|
||||
await const QRCodeScannerScreen().open(context);
|
||||
if (captureResult == null) return;
|
||||
final link = LinkParser.simple(captureResult);
|
||||
if (link != null && context.mounted) {
|
||||
context.pop();
|
||||
await NewProfileRoute(
|
||||
url: link.url,
|
||||
name: link.name,
|
||||
).push(context);
|
||||
} else {
|
||||
CustomToast.error(
|
||||
t.profile.add.invalidUrlMsg.sentenceCase,
|
||||
).show(context);
|
||||
}
|
||||
},
|
||||
)
|
||||
else
|
||||
_Button(
|
||||
label: t.profile.add.manually.sentenceCase,
|
||||
icon: Icons.add,
|
||||
size: buttonWidth,
|
||||
onTap: () async {
|
||||
context.pop();
|
||||
await const NewProfileRoute().push(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (!PlatformUtils.isDesktop)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: buttonsPadding,
|
||||
vertical: 16,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 36,
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
surfaceTintColor: Theme.of(context).colorScheme.surfaceTint,
|
||||
shadowColor: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
context.pop();
|
||||
await const NewProfileRoute().push(context);
|
||||
},
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.add,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
t.profile.add.manually.sentenceCase,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelLarge
|
||||
?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Button extends StatelessWidget {
|
||||
const _Button({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.size,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final double size;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = Theme.of(context).colorScheme.primary;
|
||||
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
surfaceTintColor: Theme.of(context).colorScheme.surfaceTint,
|
||||
shadowColor: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: size / 3,
|
||||
color: color,
|
||||
),
|
||||
const Gap(16),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelLarge
|
||||
?.copyWith(color: color),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
54
lib/features/common/clash/clash_controller.dart
Normal file
54
lib/features/common/clash/clash_controller.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'package:hiddify/core/prefs/prefs.dart';
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/domain/constants.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/features/common/active_profile/active_profile_notifier.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'clash_controller.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class ClashController extends _$ClashController with AppLogger {
|
||||
Profile? _oldProfile;
|
||||
|
||||
@override
|
||||
Future<void> build() async {
|
||||
final clash = ref.watch(clashFacadeProvider);
|
||||
|
||||
final overridesListener = ref.listen(
|
||||
prefsControllerProvider.select((value) => value.clash),
|
||||
(_, overrides) async {
|
||||
loggy.debug("new clash overrides received, patching...");
|
||||
await clash.patchOverrides(overrides).getOrElse((l) => throw l).run();
|
||||
},
|
||||
);
|
||||
final overrides = overridesListener.read();
|
||||
|
||||
final activeProfile = await ref.watch(activeProfileProvider.future);
|
||||
final oldProfile = _oldProfile;
|
||||
_oldProfile = activeProfile;
|
||||
if (activeProfile != null) {
|
||||
if (oldProfile == null ||
|
||||
oldProfile.id != activeProfile.id ||
|
||||
oldProfile.lastUpdate != activeProfile.lastUpdate) {
|
||||
loggy.debug("profile changed or updated, updating clash core");
|
||||
await clash
|
||||
.changeConfigs(activeProfile.id)
|
||||
.call(clash.patchOverrides(overrides))
|
||||
.getOrElse((error) {
|
||||
loggy.warning("failed to change or patch configs, $error");
|
||||
throw error;
|
||||
}).run();
|
||||
}
|
||||
} else {
|
||||
if (oldProfile != null) {
|
||||
loggy.debug("active profile removed, resetting clash");
|
||||
await clash
|
||||
.changeConfigs(Constants.configFileName)
|
||||
.getOrElse((l) => throw l)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
lib/features/common/clash/clash_mode.dart
Normal file
23
lib/features/common/clash/clash_mode.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import 'package:hiddify/core/prefs/prefs.dart';
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/domain/clash/clash.dart';
|
||||
import 'package:hiddify/features/common/clash/clash_controller.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'clash_mode.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class ClashMode extends _$ClashMode with AppLogger {
|
||||
@override
|
||||
Future<TunnelMode?> build() async {
|
||||
final clash = ref.watch(clashFacadeProvider);
|
||||
await ref.watch(clashControllerProvider.future);
|
||||
ref.watch(prefsControllerProvider.select((value) => value.clash.mode));
|
||||
return clash
|
||||
.getConfigs()
|
||||
.map((r) => r.mode)
|
||||
.getOrElse((l) => throw l)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
4
lib/features/common/common.dart
Normal file
4
lib/features/common/common.dart
Normal file
@@ -0,0 +1,4 @@
|
||||
export 'add_profile_modal.dart';
|
||||
export 'custom_app_bar.dart';
|
||||
export 'qr_code_scanner_screen.dart';
|
||||
export 'remaining_traffic_indicator.dart';
|
||||
31
lib/features/common/confirmation_dialogs.dart
Normal file
31
lib/features/common/confirmation_dialogs.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
Future<bool> showConfirmationDialog(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String message,
|
||||
IconData? icon,
|
||||
}) async {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
final localizations = MaterialLocalizations.of(context);
|
||||
return AlertDialog(
|
||||
icon: const Icon(Icons.delete_forever),
|
||||
title: Text(title),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => context.pop(true),
|
||||
child: Text(localizations.okButtonLabel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.pop(false),
|
||||
child: Text(localizations.cancelButtonLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
).then((value) => value ?? false);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:hiddify/core/prefs/prefs.dart';
|
||||
import 'package:hiddify/domain/connectivity/connectivity.dart';
|
||||
import 'package:hiddify/services/connectivity/connectivity.dart';
|
||||
import 'package:hiddify/services/service_providers.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'connectivity_controller.g.dart';
|
||||
|
||||
// TODO: test and improve
|
||||
// TODO: abort connection on clash error
|
||||
@Riverpod(keepAlive: true)
|
||||
class ConnectivityController extends _$ConnectivityController with AppLogger {
|
||||
@override
|
||||
ConnectionStatus build() {
|
||||
state = const Disconnected();
|
||||
final connection = _connectivity
|
||||
.watchConnectionStatus()
|
||||
.map(ConnectionStatus.fromBool)
|
||||
.listen((event) => state = event);
|
||||
|
||||
// currently changes wont take effect while connected
|
||||
ref.listen(
|
||||
prefsControllerProvider.select((value) => value.network),
|
||||
(_, next) => _networkPrefs = next,
|
||||
fireImmediately: true,
|
||||
);
|
||||
ref.listen(
|
||||
prefsControllerProvider
|
||||
.select((value) => (value.clash.httpPort!, value.clash.socksPort!)),
|
||||
(_, next) => _ports = (http: next.$1, socks: next.$2),
|
||||
fireImmediately: true,
|
||||
);
|
||||
|
||||
ref.onDispose(connection.cancel);
|
||||
return state;
|
||||
}
|
||||
|
||||
ConnectivityService get _connectivity =>
|
||||
ref.watch(connectivityServiceProvider);
|
||||
|
||||
late ({int http, int socks}) _ports;
|
||||
// ignore: unused_field
|
||||
late NetworkPrefs _networkPrefs;
|
||||
|
||||
Future<void> toggleConnection() async {
|
||||
switch (state) {
|
||||
case Disconnected():
|
||||
if (!await _connectivity.grantVpnPermission()) {
|
||||
state = const Disconnected(ConnectivityFailure.unexpected());
|
||||
return;
|
||||
}
|
||||
await _connectivity.connect(
|
||||
httpPort: _ports.http,
|
||||
socksPort: _ports.socks,
|
||||
);
|
||||
case Connected():
|
||||
await _connectivity.disconnect();
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
24
lib/features/common/custom_app_bar.dart
Normal file
24
lib/features/common/custom_app_bar.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
abstract class RootScaffold {
|
||||
static final stateKey = GlobalKey<ScaffoldState>();
|
||||
}
|
||||
|
||||
class NestedTabAppBar extends SliverAppBar {
|
||||
NestedTabAppBar({
|
||||
super.key,
|
||||
super.title,
|
||||
super.actions,
|
||||
super.pinned = true,
|
||||
super.forceElevated,
|
||||
super.bottom,
|
||||
}) : super(
|
||||
leading: RootScaffold.stateKey.currentState?.hasDrawer ?? false
|
||||
? DrawerButton(
|
||||
onPressed: () {
|
||||
RootScaffold.stateKey.currentState?.openDrawer();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
82
lib/features/common/qr_code_scanner_screen.dart
Normal file
82
lib/features/common/qr_code_scanner_screen.dart
Normal file
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
|
||||
class QRCodeScannerScreen extends HookConsumerWidget with PresLogger {
|
||||
const QRCodeScannerScreen({super.key});
|
||||
|
||||
Future<String?> open(BuildContext context) async {
|
||||
return Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (context) => const QRCodeScannerScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controller = useMemoized(
|
||||
() => MobileScannerController(
|
||||
detectionSpeed: DetectionSpeed.noDuplicates,
|
||||
formats: [BarcodeFormat.qrCode],
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => controller.dispose, []);
|
||||
|
||||
return Scaffold(
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
iconTheme: Theme.of(context).iconTheme.copyWith(
|
||||
color: Colors.white,
|
||||
size: 32,
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: ValueListenableBuilder(
|
||||
valueListenable: controller.torchState,
|
||||
builder: (context, state, child) {
|
||||
switch (state) {
|
||||
case TorchState.off:
|
||||
return const Icon(Icons.flash_off, color: Colors.grey);
|
||||
case TorchState.on:
|
||||
return const Icon(Icons.flash_on, color: Colors.yellow);
|
||||
}
|
||||
},
|
||||
),
|
||||
onPressed: () => controller.toggleTorch(),
|
||||
),
|
||||
IconButton(
|
||||
icon: ValueListenableBuilder(
|
||||
valueListenable: controller.cameraFacingState,
|
||||
builder: (context, state, child) {
|
||||
switch (state) {
|
||||
case CameraFacing.front:
|
||||
return const Icon(Icons.camera_front);
|
||||
case CameraFacing.back:
|
||||
return const Icon(Icons.camera_rear);
|
||||
}
|
||||
},
|
||||
),
|
||||
onPressed: () => controller.switchCamera(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: MobileScanner(
|
||||
controller: controller,
|
||||
onDetect: (capture) {
|
||||
final data = capture.barcodes.first;
|
||||
if (context.mounted && data.type == BarcodeType.url) {
|
||||
loggy.debug('captured raw: [${data.rawValue}]');
|
||||
loggy.debug('captured url: [${data.url?.url}]');
|
||||
Navigator.of(context, rootNavigator: true).pop(data.url?.url);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
34
lib/features/common/remaining_traffic_indicator.dart
Normal file
34
lib/features/common/remaining_traffic_indicator.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:percent_indicator/percent_indicator.dart';
|
||||
|
||||
// TODO: change colors
|
||||
class RemainingTrafficIndicator extends StatelessWidget {
|
||||
const RemainingTrafficIndicator(this.ratio, {super.key});
|
||||
|
||||
final double ratio;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final startColor = ratio < 0.25
|
||||
? const Color.fromRGBO(93, 205, 251, 1.0)
|
||||
: ratio < 0.65
|
||||
? const Color.fromRGBO(205, 199, 64, 1.0)
|
||||
: const Color.fromRGBO(241, 82, 81, 1.0);
|
||||
final endColor = ratio < 0.25
|
||||
? const Color.fromRGBO(49, 146, 248, 1.0)
|
||||
: ratio < 0.65
|
||||
? const Color.fromRGBO(98, 115, 32, 1.0)
|
||||
: const Color.fromRGBO(139, 30, 36, 1.0);
|
||||
|
||||
return LinearPercentIndicator(
|
||||
percent: ratio,
|
||||
animation: true,
|
||||
padding: EdgeInsets.zero,
|
||||
lineHeight: 6,
|
||||
barRadius: const Radius.circular(16),
|
||||
linearGradient: LinearGradient(
|
||||
colors: [startColor, endColor],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
94
lib/features/common/traffic/traffic_chart.dart
Normal file
94
lib/features/common/traffic/traffic_chart.dart
Normal file
@@ -0,0 +1,94 @@
|
||||
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):
|
||||
final latest =
|
||||
traffics.lastOrNull ?? const Traffic(upload: 0, download: 0);
|
||||
final latestUploadData = formatByteSpeed(latest.upload);
|
||||
final latestDownloadData = formatByteSpeed(latest.download);
|
||||
|
||||
final uploadChartSpots = traffics.takeLast(chartSteps).mapIndexed(
|
||||
(index, p) => FlSpot(index.toDouble(), p.upload.toDouble()),
|
||||
);
|
||||
final downloadChartSpots = traffics.takeLast(chartSteps).mapIndexed(
|
||||
(index, p) => FlSpot(index.toDouble(), p.download.toDouble()),
|
||||
);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
// mainAxisAlignment: MainAxisAlignment.end,
|
||||
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),
|
||||
],
|
||||
);
|
||||
// TODO: handle loading and error
|
||||
default:
|
||||
return const SizedBox();
|
||||
}
|
||||
}
|
||||
}
|
||||
40
lib/features/common/traffic/traffic_notifier.dart
Normal file
40
lib/features/common/traffic/traffic_notifier.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
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/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() {
|
||||
return Stream.periodic(const Duration(seconds: 1)).asyncMap(
|
||||
(_) async {
|
||||
return ref.read(clashFacadeProvider).getTraffic().match(
|
||||
(f) {
|
||||
loggy.warning('failed to watch clash traffic: $f');
|
||||
return const ClashTraffic(upload: 0, download: 0);
|
||||
},
|
||||
(traffic) => traffic,
|
||||
).run();
|
||||
},
|
||||
).map(
|
||||
(event) => switch (state) {
|
||||
AsyncData(:final value) => [
|
||||
...value.takeLast(_steps - 1),
|
||||
Traffic(upload: event.upload, download: event.download),
|
||||
],
|
||||
_ => List.generate(
|
||||
_steps,
|
||||
(index) => const Traffic(upload: 0, download: 0),
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user