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),
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
84
lib/features/home/view/home_page.dart
Normal file
84
lib/features/home/view/home_page.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/core/router/router.dart';
|
||||
import 'package:hiddify/domain/failures.dart';
|
||||
import 'package:hiddify/features/common/active_profile/active_profile_notifier.dart';
|
||||
import 'package:hiddify/features/common/active_profile/has_any_profile_notifier.dart';
|
||||
import 'package:hiddify/features/common/clash/clash_controller.dart';
|
||||
import 'package:hiddify/features/common/common.dart';
|
||||
import 'package:hiddify/features/home/widgets/widgets.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:recase/recase.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
|
||||
class HomePage extends HookConsumerWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
final hasAnyProfile = ref.watch(hasAnyProfileProvider);
|
||||
final activeProfile = ref.watch(activeProfileProvider);
|
||||
|
||||
ref.listen(
|
||||
clashControllerProvider,
|
||||
(_, next) {
|
||||
if (next case AsyncError(:final error)) {
|
||||
CustomToast.error(
|
||||
t.presentError(error),
|
||||
duration: const Duration(seconds: 10),
|
||||
).show(context);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
alignment: Alignment.bottomCenter,
|
||||
children: [
|
||||
CustomScrollView(
|
||||
slivers: [
|
||||
NestedTabAppBar(
|
||||
title: Text(t.general.appTitle.titleCase),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => const AddProfileRoute().push(context),
|
||||
icon: const Icon(Icons.add_circle),
|
||||
),
|
||||
],
|
||||
),
|
||||
switch (activeProfile) {
|
||||
AsyncData(value: final profile?) => MultiSliver(
|
||||
children: [
|
||||
ActiveProfileCard(profile),
|
||||
const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 8,
|
||||
right: 8,
|
||||
top: 8,
|
||||
bottom: 86,
|
||||
),
|
||||
child: ConnectionButton(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
AsyncData() => switch (hasAnyProfile) {
|
||||
AsyncData(value: true) =>
|
||||
const EmptyActiveProfileHomeBody(),
|
||||
_ => const EmptyProfilesHomeBody(),
|
||||
},
|
||||
AsyncError(:final error) =>
|
||||
SliverErrorBodyPlaceholder(t.presentError(error)),
|
||||
_ => const SliverToBoxAdapter(),
|
||||
},
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1
lib/features/home/view/view.dart
Normal file
1
lib/features/home/view/view.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'home_page.dart';
|
||||
171
lib/features/home/widgets/active_profile_card.dart
Normal file
171
lib/features/home/widgets/active_profile_card.dart
Normal file
@@ -0,0 +1,171 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/core/router/router.dart';
|
||||
import 'package:hiddify/domain/failures.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/features/common/active_profile/active_profile_notifier.dart';
|
||||
import 'package:hiddify/features/common/common.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:recase/recase.dart';
|
||||
|
||||
// TODO: rewrite
|
||||
class ActiveProfileCard extends HookConsumerWidget {
|
||||
const ActiveProfileCard(this.profile, {super.key});
|
||||
|
||||
final Profile profile;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: Colors.transparent,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
await const ProfilesRoute().push(context);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
profile.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
const Gap(4),
|
||||
const Icon(Icons.arrow_drop_down),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
const AddProfileRoute().push(context);
|
||||
},
|
||||
label: Text(t.profile.add.buttonText.titleCase),
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (profile.hasSubscriptionInfo) ...[
|
||||
const Divider(thickness: 0.5),
|
||||
SubscriptionInfoTile(profile.subInfo!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SubscriptionInfoTile extends HookConsumerWidget {
|
||||
const SubscriptionInfoTile(this.subInfo, {super.key});
|
||||
|
||||
final SubscriptionInfo subInfo;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (!subInfo.isValid) return const SizedBox.shrink();
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
final themeData = Theme.of(context);
|
||||
|
||||
final updateProfileMutation = useMutation(
|
||||
initialOnFailure: (err) {
|
||||
CustomToast.error(t.presentError(err)).show(context);
|
||||
},
|
||||
initialOnSuccess: () =>
|
||||
CustomToast.success(t.profile.update.successMsg).show(context),
|
||||
);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
formatTrafficByteSize(
|
||||
subInfo.consumption,
|
||||
subInfo.total!,
|
||||
),
|
||||
style: themeData.textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
t.profile.subscription.traffic,
|
||||
style: themeData.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
RemainingTrafficIndicator(subInfo.ratio),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
if (updateProfileMutation.state.isInProgress) return;
|
||||
updateProfileMutation.setFuture(
|
||||
ref.read(activeProfileProvider.notifier).updateProfile(),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.refresh, size: 44),
|
||||
),
|
||||
const Gap(8),
|
||||
if (subInfo.isExpired)
|
||||
Text(
|
||||
t.profile.subscription.expired,
|
||||
style: themeData.textTheme.titleSmall
|
||||
?.copyWith(color: themeData.colorScheme.error),
|
||||
)
|
||||
else if (subInfo.ratio >= 1)
|
||||
Text(
|
||||
t.profile.subscription.noTraffic,
|
||||
style: themeData.textTheme.titleSmall
|
||||
?.copyWith(color: themeData.colorScheme.error),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
formatExpireDuration(subInfo.remaining),
|
||||
style: themeData.textTheme.titleSmall,
|
||||
),
|
||||
Text(
|
||||
t.profile.subscription.remaining,
|
||||
style: themeData.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
70
lib/features/home/widgets/connection_button.dart
Normal file
70
lib/features/home/widgets/connection_button.dart
Normal file
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/core/theme/theme.dart';
|
||||
import 'package:hiddify/features/common/connectivity/connectivity_controller.dart';
|
||||
import 'package:hiddify/gen/assets.gen.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
// TODO: rewrite
|
||||
class ConnectionButton extends HookConsumerWidget {
|
||||
const ConnectionButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
final connectionStatus = ref.watch(connectivityControllerProvider);
|
||||
|
||||
final Color connectionLogoColor = connectionStatus.isConnected
|
||||
? ConnectionButtonColor.connected
|
||||
: ConnectionButtonColor.disconnected;
|
||||
|
||||
final bool intractable = !connectionStatus.isSwitching;
|
||||
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
blurRadius: 16,
|
||||
color: connectionLogoColor.withOpacity(0.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
width: 148,
|
||||
height: 148,
|
||||
child: Material(
|
||||
shape: const CircleBorder(),
|
||||
color: Colors.white,
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
await ref
|
||||
.read(connectivityControllerProvider.notifier)
|
||||
.toggleConnection();
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(36),
|
||||
child: Assets.images.logo.svg(
|
||||
colorFilter: ColorFilter.mode(
|
||||
connectionLogoColor,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
).animate(target: intractable ? 0 : 1).blurXY(end: 1),
|
||||
).animate(target: intractable ? 0 : 1).scaleXY(end: .88),
|
||||
const Gap(16),
|
||||
Text(
|
||||
connectionStatus.present(t),
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
55
lib/features/home/widgets/empty_profiles_home_body.dart
Normal file
55
lib/features/home/widgets/empty_profiles_home_body.dart
Normal file
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/core/router/router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:recase/recase.dart';
|
||||
|
||||
class EmptyProfilesHomeBody extends HookConsumerWidget {
|
||||
const EmptyProfilesHomeBody({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
return SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(t.home.emptyProfilesMsg.sentenceCase),
|
||||
const Gap(16),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => const AddProfileRoute().push(context),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(t.profile.add.buttonText.titleCase),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class EmptyActiveProfileHomeBody extends HookConsumerWidget {
|
||||
const EmptyActiveProfileHomeBody({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
return SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(t.home.noActiveProfileMsg.sentenceCase),
|
||||
const Gap(16),
|
||||
OutlinedButton(
|
||||
onPressed: () => const ProfilesRoute().push(context),
|
||||
child: Text(t.profile.overviewPageTitle.titleCase),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
3
lib/features/home/widgets/widgets.dart
Normal file
3
lib/features/home/widgets/widgets.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
export 'active_profile_card.dart';
|
||||
export 'connection_button.dart';
|
||||
export 'empty_profiles_home_body.dart';
|
||||
81
lib/features/logs/notifier/logs_notifier.dart
Normal file
81
lib/features/logs/notifier/logs_notifier.dart
Normal file
@@ -0,0 +1,81 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/domain/clash/clash.dart';
|
||||
import 'package:hiddify/features/logs/notifier/logs_state.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'logs_notifier.g.dart';
|
||||
|
||||
// TODO: rewrite
|
||||
@riverpod
|
||||
class LogsNotifier extends _$LogsNotifier with AppLogger {
|
||||
static const maxLength = 1000;
|
||||
|
||||
@override
|
||||
Stream<LogsState> build() {
|
||||
state = const AsyncData(LogsState());
|
||||
return ref.read(clashFacadeProvider).watchLogs().asyncMap(
|
||||
(event) async {
|
||||
_logs = [
|
||||
event.getOrElse((l) => throw l),
|
||||
..._logs.takeFirst(maxLength - 1),
|
||||
];
|
||||
return switch (state) {
|
||||
// ignore: unused_result
|
||||
AsyncData(:final value) => value.copyWith(logs: await _computeLogs()),
|
||||
_ => LogsState(logs: await _computeLogs()),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
var _logs = <ClashLog>[];
|
||||
final _debouncer = CallbackDebouncer(const Duration(milliseconds: 200));
|
||||
LogLevel? _levelFilter;
|
||||
String _filter = "";
|
||||
|
||||
Future<List<ClashLog>> _computeLogs() async {
|
||||
if (_levelFilter == null && _filter.isEmpty) return _logs;
|
||||
return _logs.where((e) {
|
||||
return (_filter.isEmpty || e.message.contains(_filter)) &&
|
||||
(_levelFilter == null || e.level == _levelFilter);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
void clear() {
|
||||
if (state case AsyncData(:final value)) {
|
||||
state = AsyncData(value.copyWith(logs: [])).copyWithPrevious(state);
|
||||
}
|
||||
}
|
||||
|
||||
void filterMessage(String? filter) {
|
||||
_filter = filter ?? '';
|
||||
_debouncer(
|
||||
() async {
|
||||
if (state case AsyncData(:final value)) {
|
||||
state = AsyncData(
|
||||
value.copyWith(
|
||||
filter: _filter,
|
||||
logs: await _computeLogs(),
|
||||
),
|
||||
).copyWithPrevious(state);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> filterLevel(LogLevel? level) async {
|
||||
_levelFilter = level;
|
||||
if (state case AsyncData(:final value)) {
|
||||
state = AsyncData(
|
||||
value.copyWith(
|
||||
levelFilter: _levelFilter,
|
||||
logs: await _computeLogs(),
|
||||
),
|
||||
).copyWithPrevious(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
15
lib/features/logs/notifier/logs_state.dart
Normal file
15
lib/features/logs/notifier/logs_state.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hiddify/domain/clash/clash.dart';
|
||||
|
||||
part 'logs_state.freezed.dart';
|
||||
|
||||
@freezed
|
||||
class LogsState with _$LogsState {
|
||||
const LogsState._();
|
||||
|
||||
const factory LogsState({
|
||||
@Default([]) List<ClashLog> logs,
|
||||
@Default("") String filter,
|
||||
LogLevel? levelFilter,
|
||||
}) = _LogsState;
|
||||
}
|
||||
2
lib/features/logs/notifier/notifier.dart
Normal file
2
lib/features/logs/notifier/notifier.dart
Normal file
@@ -0,0 +1,2 @@
|
||||
export 'logs_notifier.dart';
|
||||
export 'logs_state.dart';
|
||||
138
lib/features/logs/view/logs_page.dart
Normal file
138
lib/features/logs/view/logs_page.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/domain/clash/clash.dart';
|
||||
import 'package:hiddify/domain/failures.dart';
|
||||
import 'package:hiddify/features/common/common.dart';
|
||||
import 'package:hiddify/features/logs/notifier/notifier.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:recase/recase.dart';
|
||||
|
||||
class LogsPage extends HookConsumerWidget {
|
||||
const LogsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
final asyncState = ref.watch(logsNotifierProvider);
|
||||
final notifier = ref.watch(logsNotifierProvider.notifier);
|
||||
|
||||
switch (asyncState) {
|
||||
case AsyncData(value: final state):
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
// TODO: fix height
|
||||
toolbarHeight: 90,
|
||||
title: Text(t.logs.pageTitle.titleCase),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(36),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: TextFormField(
|
||||
onChanged: notifier.filterMessage,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
hintText: t.logs.filterHint.sentenceCase,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(16),
|
||||
DropdownButton<Option<LogLevel>>(
|
||||
value: optionOf(state.levelFilter),
|
||||
onChanged: (v) {
|
||||
if (v == null) return;
|
||||
notifier.filterLevel(v.toNullable());
|
||||
},
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: none(),
|
||||
child: Text(t.logs.allLevelsFilter.sentenceCase),
|
||||
),
|
||||
...LogLevel.values.takeFirst(3).map(
|
||||
(e) => DropdownMenuItem(
|
||||
value: some(e),
|
||||
child: Text(e.name.sentenceCase),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: ListView.builder(
|
||||
itemCount: state.logs.length,
|
||||
reverse: true,
|
||||
itemBuilder: (context, index) {
|
||||
final log = state.logs[index];
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
dense: true,
|
||||
title: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: log.timeStamp),
|
||||
const TextSpan(text: " "),
|
||||
TextSpan(
|
||||
text: log.level.name.toUpperCase(),
|
||||
style: TextStyle(color: log.level.color),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
subtitle: Text(log.message),
|
||||
),
|
||||
if (index != 0)
|
||||
const Divider(
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
height: 4,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
case AsyncError(:final error):
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
NestedTabAppBar(
|
||||
title: Text(t.logs.pageTitle.titleCase),
|
||||
),
|
||||
SliverErrorBodyPlaceholder(t.presentError(error)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
case AsyncLoading():
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
NestedTabAppBar(
|
||||
title: Text(t.logs.pageTitle.titleCase),
|
||||
),
|
||||
const SliverLoadingBodyPlaceholder(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// TODO: remove
|
||||
default:
|
||||
return const Scaffold();
|
||||
}
|
||||
}
|
||||
}
|
||||
1
lib/features/logs/view/view.dart
Normal file
1
lib/features/logs/view/view.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'logs_page.dart';
|
||||
2
lib/features/profile_detail/notifier/notifier.dart
Normal file
2
lib/features/profile_detail/notifier/notifier.dart
Normal file
@@ -0,0 +1,2 @@
|
||||
export 'profile_detail_notifier.dart';
|
||||
export 'profile_detail_state.dart';
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/features/profile_detail/notifier/profile_detail_state.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
part 'profile_detail_notifier.g.dart';
|
||||
|
||||
@riverpod
|
||||
class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger {
|
||||
@override
|
||||
Future<ProfileDetailState> build(
|
||||
String id, {
|
||||
String? url,
|
||||
String? name,
|
||||
}) async {
|
||||
if (id == 'new') {
|
||||
return ProfileDetailState(
|
||||
profile: Profile(
|
||||
id: const Uuid().v4(),
|
||||
active: true,
|
||||
name: name ?? "",
|
||||
url: url ?? "",
|
||||
lastUpdate: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
final failureOrProfile = await _profilesRepo.get(id).run();
|
||||
return failureOrProfile.match(
|
||||
(l) {
|
||||
loggy.warning('failed to load profile, $l');
|
||||
throw l;
|
||||
},
|
||||
(profile) {
|
||||
if (profile == null) {
|
||||
loggy.warning('profile with id: [$id] does not exist');
|
||||
throw const ProfileNotFoundFailure();
|
||||
}
|
||||
return ProfileDetailState(profile: profile, isEditing: true);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
ProfilesRepository get _profilesRepo => ref.read(profilesRepositoryProvider);
|
||||
|
||||
void setField({String? name, String? url}) {
|
||||
if (state case AsyncData(:final value)) {
|
||||
state = AsyncData(
|
||||
value.copyWith(
|
||||
profile: value.profile.copyWith(
|
||||
name: name ?? value.profile.name,
|
||||
url: url ?? value.profile.url,
|
||||
),
|
||||
),
|
||||
).copyWithPrevious(state);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> save() async {
|
||||
if (state case AsyncData(:final value)) {
|
||||
if (value.save.isInProgress) return;
|
||||
final profile = value.profile;
|
||||
loggy.debug(
|
||||
'saving profile, url: [${profile.url}], name: [${profile.name}]',
|
||||
);
|
||||
state = AsyncData(value.copyWith(save: const MutationInProgress()))
|
||||
.copyWithPrevious(state);
|
||||
Either<ProfileFailure, Unit>? failureOrSuccess;
|
||||
if (profile.name.isBlank || profile.url.isBlank) {
|
||||
loggy.debug('profile save: invalid arguments');
|
||||
} else if (value.isEditing) {
|
||||
loggy.debug('updating profile');
|
||||
failureOrSuccess = await _profilesRepo.update(profile).run();
|
||||
} else {
|
||||
loggy.debug('adding profile, url: [${profile.url}]');
|
||||
failureOrSuccess = await _profilesRepo.add(profile).run();
|
||||
}
|
||||
state = AsyncData(
|
||||
value.copyWith(
|
||||
save: failureOrSuccess?.fold(
|
||||
(l) => MutationFailure(l),
|
||||
(_) => const MutationSuccess(),
|
||||
) ??
|
||||
value.save,
|
||||
showErrorMessages: true,
|
||||
),
|
||||
).copyWithPrevious(state);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> delete() async {
|
||||
if (state case AsyncData(:final value)) {
|
||||
if (value.delete.isInProgress) return;
|
||||
final profile = value.profile;
|
||||
loggy.debug('deleting profile');
|
||||
state = AsyncData(
|
||||
value.copyWith(delete: const MutationState.inProgress()),
|
||||
).copyWithPrevious(state);
|
||||
final result = await _profilesRepo.delete(profile.id).run();
|
||||
state = AsyncData(
|
||||
value.copyWith(
|
||||
delete: result.match(
|
||||
(l) => MutationFailure(l),
|
||||
(_) => const MutationSuccess(),
|
||||
),
|
||||
),
|
||||
).copyWithPrevious(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
|
||||
part 'profile_detail_state.freezed.dart';
|
||||
|
||||
@freezed
|
||||
class ProfileDetailState with _$ProfileDetailState {
|
||||
const ProfileDetailState._();
|
||||
|
||||
const factory ProfileDetailState({
|
||||
required Profile profile,
|
||||
@Default(false) bool isEditing,
|
||||
@Default(false) bool showErrorMessages,
|
||||
@Default(MutationState.initial()) MutationState<ProfileFailure> save,
|
||||
@Default(MutationState.initial()) MutationState<ProfileFailure> delete,
|
||||
}) = _ProfileDetailState;
|
||||
|
||||
bool get isBusy =>
|
||||
(save.isInProgress || save is MutationSuccess) ||
|
||||
(delete.isInProgress || delete is MutationSuccess);
|
||||
}
|
||||
203
lib/features/profile_detail/view/profile_detail_page.dart
Normal file
203
lib/features/profile_detail/view/profile_detail_page.dart
Normal file
@@ -0,0 +1,203 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/domain/failures.dart';
|
||||
import 'package:hiddify/features/common/confirmation_dialogs.dart';
|
||||
import 'package:hiddify/features/profile_detail/notifier/notifier.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:recase/recase.dart';
|
||||
|
||||
// TODO: test and improve
|
||||
// TODO: prevent popping screen when busy
|
||||
class ProfileDetailPage extends HookConsumerWidget with PresLogger {
|
||||
const ProfileDetailPage(
|
||||
this.id, {
|
||||
super.key,
|
||||
this.url,
|
||||
this.name,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String? url;
|
||||
final String? name;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final provider = profileDetailNotifierProvider(id, url: url, name: name);
|
||||
final t = ref.watch(translationsProvider);
|
||||
final asyncState = ref.watch(provider);
|
||||
final notifier = ref.watch(provider.notifier);
|
||||
|
||||
final themeData = Theme.of(context);
|
||||
|
||||
ref.listen(
|
||||
provider.select((data) => data.whenData((value) => value.save)),
|
||||
(_, asyncSave) {
|
||||
if (asyncSave case AsyncData(value: final save)) {
|
||||
switch (save) {
|
||||
case MutationFailure(:final failure):
|
||||
CustomToast.error(t.presentError(failure)).show(context);
|
||||
case MutationSuccess():
|
||||
CustomToast.success(t.profile.save.successMsg.sentenceCase)
|
||||
.show(context);
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) {
|
||||
if (context.mounted) context.pop();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ref.listen(
|
||||
provider.select((data) => data.whenData((value) => value.delete)),
|
||||
(_, asyncSave) {
|
||||
if (asyncSave case AsyncData(value: final delete)) {
|
||||
switch (delete) {
|
||||
case MutationFailure(:final failure):
|
||||
CustomToast.error(t.presentError(failure)).show(context);
|
||||
case MutationSuccess():
|
||||
CustomToast.success(t.profile.delete.successMsg.sentenceCase)
|
||||
.show(context);
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) {
|
||||
if (context.mounted) context.pop();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
switch (asyncState) {
|
||||
case AsyncData(value: final state):
|
||||
return Stack(
|
||||
children: [
|
||||
Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
pinned: true,
|
||||
title: Text(t.profile.detailsPageTitle.titleCase),
|
||||
),
|
||||
const SliverGap(8),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: Form(
|
||||
autovalidateMode: state.showErrorMessages
|
||||
? AutovalidateMode.always
|
||||
: AutovalidateMode.disabled,
|
||||
child: SliverList(
|
||||
delegate: SliverChildListDelegate(
|
||||
[
|
||||
const Gap(8),
|
||||
CustomTextFormField(
|
||||
initialValue: state.profile.name,
|
||||
onChanged: (value) =>
|
||||
notifier.setField(name: value),
|
||||
validator: (value) => (value?.isEmpty ?? true)
|
||||
? t.profile.detailsForm.emptyNameMsg
|
||||
: null,
|
||||
label: t.profile.detailsForm.nameHint.titleCase,
|
||||
),
|
||||
const Gap(16),
|
||||
CustomTextFormField(
|
||||
initialValue: state.profile.url,
|
||||
onChanged: (value) =>
|
||||
notifier.setField(url: value),
|
||||
validator: (value) =>
|
||||
(value != null && !isUrl(value))
|
||||
? t.profile.detailsForm.invalidUrlMsg
|
||||
: null,
|
||||
label:
|
||||
t.profile.detailsForm.urlHint.toUpperCase(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
OverflowBar(
|
||||
spacing: 12,
|
||||
overflowAlignment: OverflowBarAlignment.end,
|
||||
children: [
|
||||
if (state.isEditing)
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final deleteConfirmed =
|
||||
await showConfirmationDialog(
|
||||
context,
|
||||
title:
|
||||
t.profile.delete.buttonText.titleCase,
|
||||
message: t.profile.delete.confirmationMsg
|
||||
.sentenceCase,
|
||||
);
|
||||
if (deleteConfirmed) {
|
||||
await notifier.delete();
|
||||
}
|
||||
},
|
||||
style: ButtonStyle(
|
||||
backgroundColor: MaterialStatePropertyAll(
|
||||
themeData.colorScheme.errorContainer,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
t.profile.delete.buttonText.titleCase,
|
||||
style: TextStyle(
|
||||
color: themeData
|
||||
.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: notifier.save,
|
||||
child:
|
||||
Text(t.profile.save.buttonText.titleCase),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (state.isBusy)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: Colors.black54,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 36),
|
||||
child: const Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
LinearProgressIndicator(
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// TODO: handle loading and error states
|
||||
default:
|
||||
return const Scaffold();
|
||||
}
|
||||
}
|
||||
}
|
||||
1
lib/features/profile_detail/view/view.dart
Normal file
1
lib/features/profile_detail/view/view.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'profile_detail_page.dart';
|
||||
1
lib/features/profiles/notifier/notifier.dart
Normal file
1
lib/features/profiles/notifier/notifier.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'profiles_notifier.dart';
|
||||
38
lib/features/profiles/notifier/profiles_notifier.dart
Normal file
38
lib/features/profiles/notifier/profiles_notifier.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'dart:async';
|
||||
|
||||
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 'profiles_notifier.g.dart';
|
||||
|
||||
@riverpod
|
||||
class ProfilesNotifier extends _$ProfilesNotifier with AppLogger {
|
||||
@override
|
||||
Stream<List<Profile>> build() {
|
||||
return _profilesRepo
|
||||
.watchAll()
|
||||
.map((event) => event.getOrElse((l) => throw l));
|
||||
}
|
||||
|
||||
ProfilesRepository get _profilesRepo => ref.read(profilesRepositoryProvider);
|
||||
|
||||
Future<void> selectActiveProfile(String id) async {
|
||||
loggy.debug('changing active profile to: [$id]');
|
||||
await _profilesRepo.setAsActive(id).mapLeft((f) {
|
||||
loggy.warning('failed to set [$id] as active profile, $f');
|
||||
throw f;
|
||||
}).run();
|
||||
}
|
||||
|
||||
Future<void> deleteProfile(Profile profile) async {
|
||||
loggy.debug('deleting profile: ${profile.name}');
|
||||
await _profilesRepo.delete(profile.id).mapLeft(
|
||||
(f) {
|
||||
loggy.warning('failed to delete profile, $f');
|
||||
throw f;
|
||||
},
|
||||
).run();
|
||||
}
|
||||
}
|
||||
38
lib/features/profiles/view/profiles_modal.dart
Normal file
38
lib/features/profiles/view/profiles_modal.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hiddify/features/profiles/notifier/notifier.dart';
|
||||
import 'package:hiddify/features/profiles/widgets/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
class ProfilesModal extends HookConsumerWidget {
|
||||
const ProfilesModal({
|
||||
super.key,
|
||||
this.scrollController,
|
||||
});
|
||||
|
||||
final ScrollController? scrollController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asyncProfiles = ref.watch(profilesNotifierProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
switch (asyncProfiles) {
|
||||
AsyncData(value: final profiles) => SliverList.builder(
|
||||
itemBuilder: (context, index) {
|
||||
final profile = profiles[index];
|
||||
return ProfileTile(profile);
|
||||
},
|
||||
itemCount: profiles.length,
|
||||
),
|
||||
// TODO: handle loading and error
|
||||
_ => const SliverToBoxAdapter(),
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1
lib/features/profiles/view/view.dart
Normal file
1
lib/features/profiles/view/view.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'profiles_modal.dart';
|
||||
187
lib/features/profiles/widgets/profile_tile.dart
Normal file
187
lib/features/profiles/widgets/profile_tile.dart
Normal file
@@ -0,0 +1,187 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/core/router/router.dart';
|
||||
import 'package:hiddify/domain/failures.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/features/common/common.dart';
|
||||
import 'package:hiddify/features/common/confirmation_dialogs.dart';
|
||||
import 'package:hiddify/features/profiles/notifier/notifier.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:recase/recase.dart';
|
||||
import 'package:timeago/timeago.dart' as timeago;
|
||||
|
||||
class ProfileTile extends HookConsumerWidget {
|
||||
const ProfileTile(this.profile, {super.key});
|
||||
|
||||
final Profile profile;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
final subInfo = profile.subInfo;
|
||||
|
||||
final themeData = Theme.of(context);
|
||||
|
||||
final selectActiveMutation = useMutation(
|
||||
initialOnFailure: (err) {
|
||||
CustomToast.error(t.presentError(err)).show(context);
|
||||
},
|
||||
);
|
||||
final deleteProfileMutation = useMutation(
|
||||
initialOnFailure: (err) {
|
||||
CustomToast.error(t.presentError(err)).show(context);
|
||||
},
|
||||
);
|
||||
|
||||
return Card(
|
||||
elevation: 6,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
shadowColor: Colors.transparent,
|
||||
color: profile.active ? themeData.colorScheme.tertiaryContainer : null,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
if (profile.active || selectActiveMutation.state.isInProgress) return;
|
||||
selectActiveMutation.setFuture(
|
||||
ref
|
||||
.read(profilesNotifierProvider.notifier)
|
||||
.selectActiveProfile(profile.id),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text.rich(
|
||||
overflow: TextOverflow.ellipsis,
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: profile.name,
|
||||
style: themeData.textTheme.titleMedium,
|
||||
),
|
||||
const TextSpan(text: " • "),
|
||||
TextSpan(
|
||||
text: t.profile.subscription.updatedTimeAgo(
|
||||
timeago: timeago.format(profile.lastUpdate),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const Gap(12),
|
||||
SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 18,
|
||||
onPressed: () async {
|
||||
// await context.push(Routes.profile(profile.id).path);
|
||||
// TODO: temp
|
||||
await ProfileDetailsRoute(profile.id).push(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Gap(12),
|
||||
SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.delete_forever),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
iconSize: 18,
|
||||
onPressed: () async {
|
||||
if (deleteProfileMutation.state.isInProgress) {
|
||||
return;
|
||||
}
|
||||
final deleteConfirmed =
|
||||
await showConfirmationDialog(
|
||||
context,
|
||||
title: t.profile.delete.buttonText.titleCase,
|
||||
message:
|
||||
t.profile.delete.confirmationMsg.sentenceCase,
|
||||
);
|
||||
if (deleteConfirmed) {
|
||||
deleteProfileMutation.setFuture(
|
||||
ref
|
||||
.read(profilesNotifierProvider.notifier)
|
||||
.deleteProfile(profile),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (subInfo?.isValid ?? false) ...[
|
||||
const Gap(2),
|
||||
Row(
|
||||
children: [
|
||||
if (subInfo!.isExpired)
|
||||
Text(
|
||||
t.profile.subscription.expired,
|
||||
style: themeData.textTheme.titleSmall
|
||||
?.copyWith(color: themeData.colorScheme.error),
|
||||
)
|
||||
else if (subInfo.ratio >= 1)
|
||||
Text(
|
||||
t.profile.subscription.noTraffic,
|
||||
style: themeData.textTheme.titleSmall?.copyWith(
|
||||
color: themeData.colorScheme.error,
|
||||
),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
formatExpireDuration(subInfo.remaining),
|
||||
style: themeData.textTheme.titleSmall,
|
||||
),
|
||||
Text(
|
||||
t.profile.subscription.remaining,
|
||||
style: themeData.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(16),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
formatTrafficByteSize(
|
||||
subInfo.consumption,
|
||||
subInfo.total!,
|
||||
),
|
||||
style: themeData.textTheme.titleMedium,
|
||||
),
|
||||
RemainingTrafficIndicator(subInfo.ratio),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1
lib/features/profiles/widgets/widgets.dart
Normal file
1
lib/features/profiles/widgets/widgets.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'profile_tile.dart';
|
||||
43
lib/features/proxies/model/group_with_proxies.dart
Normal file
43
lib/features/proxies/model/group_with_proxies.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'package:combine/combine.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hiddify/domain/clash/clash.dart';
|
||||
|
||||
part 'group_with_proxies.freezed.dart';
|
||||
|
||||
@freezed
|
||||
class GroupWithProxies with _$GroupWithProxies {
|
||||
const GroupWithProxies._();
|
||||
|
||||
const factory GroupWithProxies({
|
||||
required ClashProxyGroup group,
|
||||
required List<ClashProxy> proxies,
|
||||
}) = _GroupWithProxies;
|
||||
|
||||
static Future<List<GroupWithProxies>> fromProxies(
|
||||
List<ClashProxy> proxies,
|
||||
TunnelMode? mode,
|
||||
) async {
|
||||
final stopWatch = Stopwatch()..start();
|
||||
final res = await CombineWorker().execute(
|
||||
() {
|
||||
final result = <GroupWithProxies>[];
|
||||
for (final proxy in proxies) {
|
||||
if (proxy is ClashProxyGroup) {
|
||||
if (mode != TunnelMode.global && proxy.name == "GLOBAL") continue;
|
||||
final current = <ClashProxy>[];
|
||||
for (final name in proxy.all) {
|
||||
current.addAll(proxies.where((e) => e.name == name).toList());
|
||||
}
|
||||
result.add(GroupWithProxies(group: proxy, proxies: current));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
);
|
||||
debugPrint(
|
||||
"computed grouped proxies in [${stopWatch.elapsedMilliseconds}ms]",
|
||||
);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
1
lib/features/proxies/model/model.dart
Normal file
1
lib/features/proxies/model/model.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'group_with_proxies.dart';
|
||||
1
lib/features/proxies/notifier/notifier.dart
Normal file
1
lib/features/proxies/notifier/notifier.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'proxies_notifier.dart';
|
||||
74
lib/features/proxies/notifier/proxies_delay_notifier.dart
Normal file
74
lib/features/proxies/notifier/proxies_delay_notifier.dart
Normal file
@@ -0,0 +1,74 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/domain/clash/clash.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';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
part 'proxies_delay_notifier.g.dart';
|
||||
|
||||
// TODO: rewrite
|
||||
@Riverpod(keepAlive: true)
|
||||
class ProxiesDelayNotifier extends _$ProxiesDelayNotifier with AppLogger {
|
||||
@override
|
||||
Map<String, int> build() {
|
||||
ref.onDispose(
|
||||
() {
|
||||
loggy.debug("disposing");
|
||||
_currentTest?.cancel();
|
||||
},
|
||||
);
|
||||
|
||||
ref.listen(
|
||||
activeProfileProvider.selectAsync((value) => value?.id),
|
||||
(prev, next) async {
|
||||
if (await prev != await next) ref.invalidateSelf();
|
||||
},
|
||||
);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
ClashFacade get _clash => ref.read(clashFacadeProvider);
|
||||
StreamSubscription? _currentTest;
|
||||
|
||||
Future<void> testDelay(Iterable<String> proxies) async {
|
||||
loggy.debug('testing delay for [${proxies.length}] proxies');
|
||||
|
||||
// cancel possible running test
|
||||
await _currentTest?.cancel();
|
||||
|
||||
// reset previous
|
||||
state = state.filterNot((entry) => proxies.contains(entry.key));
|
||||
|
||||
void setDelay(String name, int delay) {
|
||||
state = {
|
||||
...state
|
||||
..update(
|
||||
name,
|
||||
(_) => delay,
|
||||
ifAbsent: () => delay,
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
_currentTest = Stream.fromIterable(proxies)
|
||||
.bufferCount(5)
|
||||
.asyncMap(
|
||||
(chunk) => Future.wait(
|
||||
chunk.map(
|
||||
(e) async => setDelay(
|
||||
e,
|
||||
await _clash.testDelay(e).getOrElse((l) => -1).run(),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.listen((event) {});
|
||||
}
|
||||
|
||||
Future<void> cancelDelayTest() async => _currentTest?.cancel();
|
||||
}
|
||||
45
lib/features/proxies/notifier/proxies_notifier.dart
Normal file
45
lib/features/proxies/notifier/proxies_notifier.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fpdart/fpdart.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/features/common/clash/clash_mode.dart';
|
||||
import 'package:hiddify/features/proxies/model/model.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'proxies_notifier.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class ProxiesNotifier extends _$ProxiesNotifier with AppLogger {
|
||||
@override
|
||||
Future<List<GroupWithProxies>> build() async {
|
||||
loggy.debug('building');
|
||||
await ref.watch(clashControllerProvider.future);
|
||||
final mode = await ref.watch(clashModeProvider.future);
|
||||
return _clash
|
||||
.getProxies()
|
||||
.flatMap(
|
||||
(proxies) {
|
||||
return TaskEither(
|
||||
() async =>
|
||||
right(await GroupWithProxies.fromProxies(proxies, mode)),
|
||||
);
|
||||
},
|
||||
)
|
||||
.getOrElse((l) => throw l)
|
||||
.run();
|
||||
}
|
||||
|
||||
ClashFacade get _clash => ref.read(clashFacadeProvider);
|
||||
|
||||
Future<void> changeProxy(String selectorName, String proxyName) async {
|
||||
loggy.debug("changing proxy, selector: $selectorName - proxy: $proxyName ");
|
||||
await _clash
|
||||
.changeProxy(selectorName, proxyName)
|
||||
.getOrElse((l) => throw l)
|
||||
.run();
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
}
|
||||
188
lib/features/proxies/view/proxies_page.dart
Normal file
188
lib/features/proxies/view/proxies_page.dart
Normal file
@@ -0,0 +1,188 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/domain/failures.dart';
|
||||
import 'package:hiddify/features/common/common.dart';
|
||||
import 'package:hiddify/features/proxies/notifier/notifier.dart';
|
||||
import 'package:hiddify/features/proxies/notifier/proxies_delay_notifier.dart';
|
||||
import 'package:hiddify/features/proxies/widgets/widgets.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:recase/recase.dart';
|
||||
|
||||
// TODO: rewrite, bugs with scroll
|
||||
class ProxiesPage extends HookConsumerWidget with PresLogger {
|
||||
const ProxiesPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
final notifier = ref.watch(proxiesNotifierProvider.notifier);
|
||||
final asyncProxies = ref.watch(proxiesNotifierProvider);
|
||||
final proxies = asyncProxies.value ?? [];
|
||||
final delays = ref.watch(proxiesDelayNotifierProvider);
|
||||
|
||||
final selectActiveProxyMutation = useMutation(
|
||||
initialOnFailure: (error) =>
|
||||
CustomToast.error(t.presentError(error)).show(context),
|
||||
);
|
||||
|
||||
final tabController = useTabController(
|
||||
initialLength: proxies.length,
|
||||
keys: [proxies.length],
|
||||
);
|
||||
|
||||
switch (asyncProxies) {
|
||||
case AsyncData(value: final proxies):
|
||||
if (proxies.isEmpty) {
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
NestedTabAppBar(
|
||||
title: Text(t.proxies.pageTitle.titleCase),
|
||||
),
|
||||
SliverFillRemaining(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(t.proxies.emptyProxiesMsg.titleCase),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final tabs = [
|
||||
for (final groupWithProxies in proxies)
|
||||
Tab(
|
||||
child: Text(
|
||||
groupWithProxies.group.name.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).appBarTheme.foregroundColor,
|
||||
),
|
||||
),
|
||||
)
|
||||
];
|
||||
|
||||
final tabViews = [
|
||||
for (final groupWithProxies in proxies)
|
||||
SafeArea(
|
||||
top: false,
|
||||
bottom: false,
|
||||
child: Builder(
|
||||
builder: (BuildContext context) {
|
||||
return CustomScrollView(
|
||||
key: PageStorageKey<String>(
|
||||
groupWithProxies.group.name,
|
||||
),
|
||||
slivers: <Widget>[
|
||||
SliverList.builder(
|
||||
itemBuilder: (_, index) {
|
||||
final proxy = groupWithProxies.proxies[index];
|
||||
return ProxyTile(
|
||||
proxy,
|
||||
selected: groupWithProxies.group.now == proxy.name,
|
||||
delay: delays[proxy.name],
|
||||
onSelect: () async {
|
||||
if (selectActiveProxyMutation
|
||||
.state.isInProgress) {
|
||||
return;
|
||||
}
|
||||
selectActiveProxyMutation.setFuture(
|
||||
notifier.changeProxy(
|
||||
groupWithProxies.group.name,
|
||||
proxy.name,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
itemCount: groupWithProxies.proxies.length,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
body: NestedScrollView(
|
||||
headerSliverBuilder: (context, innerBoxIsScrolled) {
|
||||
return <Widget>[
|
||||
NestedTabAppBar(
|
||||
title: Text(t.proxies.pageTitle.titleCase),
|
||||
forceElevated: innerBoxIsScrolled,
|
||||
actions: [
|
||||
PopupMenuButton(
|
||||
itemBuilder: (_) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
onTap: ref
|
||||
.read(proxiesDelayNotifierProvider.notifier)
|
||||
.cancelDelayTest,
|
||||
child: Text(
|
||||
t.proxies.cancelTestButtonText.sentenceCase,
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
],
|
||||
bottom: TabBar(
|
||||
controller: tabController,
|
||||
isScrollable: true,
|
||||
tabs: tabs,
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
body: TabBarView(
|
||||
controller: tabController,
|
||||
children: tabViews,
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () async =>
|
||||
// TODO: improve
|
||||
ref.read(proxiesDelayNotifierProvider.notifier).testDelay(
|
||||
proxies[tabController.index].proxies.map((e) => e.name),
|
||||
),
|
||||
tooltip: t.proxies.delayTestTooltip.titleCase,
|
||||
child: const Icon(Icons.bolt),
|
||||
),
|
||||
);
|
||||
|
||||
case AsyncError(:final error):
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
NestedTabAppBar(
|
||||
title: Text(t.proxies.pageTitle.titleCase),
|
||||
),
|
||||
SliverErrorBodyPlaceholder(t.presentError(error)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
case AsyncLoading():
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
NestedTabAppBar(
|
||||
title: Text(t.proxies.pageTitle.titleCase),
|
||||
),
|
||||
const SliverLoadingBodyPlaceholder(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// TODO: remove
|
||||
default:
|
||||
return const Scaffold();
|
||||
}
|
||||
}
|
||||
}
|
||||
1
lib/features/proxies/view/view.dart
Normal file
1
lib/features/proxies/view/view.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'proxies_page.dart';
|
||||
33
lib/features/proxies/widgets/proxy_tile.dart
Normal file
33
lib/features/proxies/widgets/proxy_tile.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hiddify/domain/clash/clash.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
// TODO: rewrite
|
||||
class ProxyTile extends HookConsumerWidget {
|
||||
const ProxyTile(
|
||||
this.proxy, {
|
||||
super.key,
|
||||
required this.selected,
|
||||
required this.onSelect,
|
||||
this.delay,
|
||||
});
|
||||
|
||||
final ClashProxy proxy;
|
||||
final bool selected;
|
||||
final VoidCallback onSelect;
|
||||
final int? delay;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ListTile(
|
||||
title: Text(
|
||||
proxy.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(proxy.type.label),
|
||||
trailing: delay != null ? Text(delay.toString()) : null,
|
||||
selected: selected,
|
||||
onTap: onSelect,
|
||||
);
|
||||
}
|
||||
}
|
||||
1
lib/features/proxies/widgets/widgets.dart
Normal file
1
lib/features/proxies/widgets/widgets.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'proxy_tile.dart';
|
||||
60
lib/features/settings/view/settings_page.dart
Normal file
60
lib/features/settings/view/settings_page.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/features/settings/widgets/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:recase/recase.dart';
|
||||
|
||||
class SettingsPage extends HookConsumerWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
const divider = Divider(indent: 16, endIndent: 16);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(t.settings.pageTitle.titleCase),
|
||||
),
|
||||
body: ListTileTheme(
|
||||
data: ListTileTheme.of(context).copyWith(
|
||||
contentPadding: const EdgeInsetsDirectional.only(start: 48, end: 16),
|
||||
),
|
||||
child: ListView(
|
||||
children: [
|
||||
_SettingsSectionHeader(
|
||||
t.settings.appearance.sectionTitle.titleCase,
|
||||
),
|
||||
const AppearanceSettingTiles(),
|
||||
divider,
|
||||
_SettingsSectionHeader(t.settings.network.sectionTitle.titleCase),
|
||||
const NetworkSettingTiles(),
|
||||
divider,
|
||||
_SettingsSectionHeader(t.settings.clash.sectionTitle.titleCase),
|
||||
const ClashSettingTiles(),
|
||||
const Gap(16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsSectionHeader extends StatelessWidget {
|
||||
const _SettingsSectionHeader(this.title);
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1
lib/features/settings/view/view.dart
Normal file
1
lib/features/settings/view/view.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'settings_page.dart';
|
||||
54
lib/features/settings/widgets/appearance_setting_tiles.dart
Normal file
54
lib/features/settings/widgets/appearance_setting_tiles.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/core/theme/theme.dart';
|
||||
import 'package:hiddify/features/settings/widgets/theme_mode_switch_button.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:recase/recase.dart';
|
||||
|
||||
class AppearanceSettingTiles extends HookConsumerWidget {
|
||||
const AppearanceSettingTiles({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
final theme = ref.watch(themeControllerProvider);
|
||||
final themeController = ref.watch(themeControllerProvider.notifier);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text(t.settings.appearance.themeMode.titleCase),
|
||||
subtitle: Text(
|
||||
switch (theme.themeMode) {
|
||||
ThemeMode.system => t.settings.appearance.themeModes.system,
|
||||
ThemeMode.light => t.settings.appearance.themeModes.light,
|
||||
ThemeMode.dark => t.settings.appearance.themeModes.dark,
|
||||
}
|
||||
.sentenceCase,
|
||||
),
|
||||
trailing: ThemeModeSwitch(
|
||||
themeMode: theme.themeMode,
|
||||
onChanged: (value) {
|
||||
themeController.change(themeMode: value);
|
||||
},
|
||||
),
|
||||
onTap: () async {
|
||||
await themeController.change(
|
||||
themeMode: Theme.of(context).brightness == Brightness.light
|
||||
? ThemeMode.dark
|
||||
: ThemeMode.light,
|
||||
);
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
title: Text(t.settings.appearance.trueBlack.titleCase),
|
||||
value: theme.trueBlack,
|
||||
onChanged: (value) {
|
||||
themeController.change(trueBlack: value);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
241
lib/features/settings/widgets/clash_setting_tiles.dart
Normal file
241
lib/features/settings/widgets/clash_setting_tiles.dart
Normal file
@@ -0,0 +1,241 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/core/prefs/prefs.dart';
|
||||
import 'package:hiddify/domain/clash/clash.dart';
|
||||
import 'package:hiddify/features/settings/widgets/settings_input_dialog.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:recase/recase.dart';
|
||||
|
||||
class ClashSettingTiles extends HookConsumerWidget {
|
||||
const ClashSettingTiles({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
final overrides =
|
||||
ref.watch(prefsControllerProvider.select((value) => value.clash));
|
||||
final notifier = ref.watch(prefsControllerProvider.notifier);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
InputOverrideTile(
|
||||
title: t.settings.clash.overrides.httpPort,
|
||||
value: overrides.httpPort,
|
||||
resetValue: ClashConfig.initial.httpPort,
|
||||
onChange: (value) => notifier.patchClashOverrides(
|
||||
ClashConfigPatch(httpPort: value),
|
||||
),
|
||||
),
|
||||
InputOverrideTile(
|
||||
title: t.settings.clash.overrides.socksPort,
|
||||
value: overrides.socksPort,
|
||||
resetValue: ClashConfig.initial.socksPort,
|
||||
onChange: (value) => notifier.patchClashOverrides(
|
||||
ClashConfigPatch(socksPort: value),
|
||||
),
|
||||
),
|
||||
InputOverrideTile(
|
||||
title: t.settings.clash.overrides.redirPort,
|
||||
value: overrides.redirPort,
|
||||
onChange: (value) => notifier.patchClashOverrides(
|
||||
ClashConfigPatch(redirPort: value),
|
||||
),
|
||||
),
|
||||
InputOverrideTile(
|
||||
title: t.settings.clash.overrides.tproxyPort,
|
||||
value: overrides.tproxyPort,
|
||||
onChange: (value) => notifier.patchClashOverrides(
|
||||
ClashConfigPatch(tproxyPort: value),
|
||||
),
|
||||
),
|
||||
InputOverrideTile(
|
||||
title: t.settings.clash.overrides.mixedPort,
|
||||
value: overrides.mixedPort,
|
||||
resetValue: ClashConfig.initial.mixedPort,
|
||||
onChange: (value) => notifier.patchClashOverrides(
|
||||
ClashConfigPatch(mixedPort: value),
|
||||
),
|
||||
),
|
||||
ToggleOverrideTile(
|
||||
title: t.settings.clash.overrides.allowLan,
|
||||
value: overrides.allowLan,
|
||||
onChange: (value) => notifier.patchClashOverrides(
|
||||
ClashConfigPatch(allowLan: value),
|
||||
),
|
||||
),
|
||||
ToggleOverrideTile(
|
||||
title: t.settings.clash.overrides.ipv6,
|
||||
value: overrides.ipv6,
|
||||
onChange: (value) => notifier.patchClashOverrides(
|
||||
ClashConfigPatch(ipv6: value),
|
||||
),
|
||||
),
|
||||
ChoiceOverrideTile(
|
||||
title: t.settings.clash.overrides.mode,
|
||||
value: overrides.mode,
|
||||
options: TunnelMode.values,
|
||||
onChange: (value) => notifier.patchClashOverrides(
|
||||
ClashConfigPatch(mode: value),
|
||||
),
|
||||
),
|
||||
ChoiceOverrideTile(
|
||||
title: t.settings.clash.overrides.logLevel,
|
||||
value: overrides.logLevel,
|
||||
options: LogLevel.values,
|
||||
onChange: (value) => notifier.patchClashOverrides(
|
||||
ClashConfigPatch(logLevel: value),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class InputOverrideTile extends HookConsumerWidget {
|
||||
const InputOverrideTile({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.value,
|
||||
this.resetValue,
|
||||
required this.onChange,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final int? value;
|
||||
final int? resetValue;
|
||||
final ValueChanged<Option<int>> onChange;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
return ListTile(
|
||||
title: Text(title),
|
||||
leadingAndTrailingTextStyle: Theme.of(context).textTheme.bodyMedium,
|
||||
trailing: Text(
|
||||
value == null
|
||||
? t.settings.clash.doNotModify.sentenceCase
|
||||
: value.toString(),
|
||||
),
|
||||
onTap: () async {
|
||||
final result = await SettingsInputDialog<int>(
|
||||
title: title,
|
||||
initialValue: value,
|
||||
resetValue: optionOf(resetValue),
|
||||
).show(context).then(
|
||||
(value) {
|
||||
return value?.match<Option<int>?>(
|
||||
() => none(),
|
||||
(t) {
|
||||
final i = int.tryParse(t);
|
||||
return i == null ? null : some(i);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
if (result == null) return;
|
||||
onChange(result);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ToggleOverrideTile extends HookConsumerWidget {
|
||||
const ToggleOverrideTile({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.onChange,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final bool? value;
|
||||
final ValueChanged<Option<bool>> onChange;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
return PopupMenuButton<Option<bool>>(
|
||||
initialValue: optionOf(value),
|
||||
onSelected: onChange,
|
||||
child: ListTile(
|
||||
title: Text(title),
|
||||
leadingAndTrailingTextStyle: Theme.of(context).textTheme.bodyMedium,
|
||||
trailing: Text(
|
||||
(value == null
|
||||
? t.settings.clash.doNotModify
|
||||
: value!
|
||||
? t.general.toggle.enabled
|
||||
: t.general.toggle.disabled)
|
||||
.sentenceCase,
|
||||
),
|
||||
),
|
||||
itemBuilder: (_) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
value: none(),
|
||||
child: Text(t.settings.clash.doNotModify.sentenceCase),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: some(true),
|
||||
child: Text(t.general.toggle.enabled.sentenceCase),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: some(false),
|
||||
child: Text(t.general.toggle.disabled.sentenceCase),
|
||||
),
|
||||
];
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChoiceOverrideTile<T extends Enum> extends HookConsumerWidget {
|
||||
const ChoiceOverrideTile({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.options,
|
||||
required this.onChange,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final T? value;
|
||||
final List<T> options;
|
||||
final ValueChanged<Option<T>> onChange;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
return PopupMenuButton<Option<T>>(
|
||||
initialValue: optionOf(value),
|
||||
onSelected: onChange,
|
||||
child: ListTile(
|
||||
title: Text(title),
|
||||
leadingAndTrailingTextStyle: Theme.of(context).textTheme.bodyMedium,
|
||||
trailing: Text(
|
||||
(value == null ? t.settings.clash.doNotModify : value!.name)
|
||||
.sentenceCase,
|
||||
),
|
||||
),
|
||||
itemBuilder: (_) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
value: none(),
|
||||
child: Text(t.settings.clash.doNotModify.sentenceCase),
|
||||
),
|
||||
...options.map(
|
||||
(e) => PopupMenuItem(
|
||||
value: some(e),
|
||||
child: Text(e.name.sentenceCase),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
36
lib/features/settings/widgets/network_setting_tiles.dart
Normal file
36
lib/features/settings/widgets/network_setting_tiles.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/core/prefs/prefs.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:recase/recase.dart';
|
||||
|
||||
class NetworkSettingTiles extends HookConsumerWidget {
|
||||
const NetworkSettingTiles({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
final prefs =
|
||||
ref.watch(prefsControllerProvider.select((value) => value.network));
|
||||
final notifier = ref.watch(prefsControllerProvider.notifier);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SwitchListTile(
|
||||
title: Text(t.settings.network.systemProxy.titleCase),
|
||||
subtitle: Text(t.settings.network.systemProxyMsg),
|
||||
value: prefs.systemProxy,
|
||||
onChanged: (value) => notifier.patchNetworkPrefs(systemProxy: value),
|
||||
),
|
||||
SwitchListTile(
|
||||
title: Text(t.settings.network.bypassPrivateNetworks.titleCase),
|
||||
subtitle: Text(t.settings.network.bypassPrivateNetworksMsg),
|
||||
value: prefs.bypassPrivateNetworks,
|
||||
onChanged: (value) =>
|
||||
notifier.patchNetworkPrefs(bypassPrivateNetworks: value),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
77
lib/features/settings/widgets/settings_input_dialog.dart
Normal file
77
lib/features/settings/widgets/settings_input_dialog.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
class SettingsInputDialog<T> extends HookConsumerWidget with PresLogger {
|
||||
const SettingsInputDialog({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.initialValue,
|
||||
this.resetValue = const None(),
|
||||
this.icon,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final T? initialValue;
|
||||
|
||||
/// default value, useful for mandatory fields
|
||||
final Option<T> resetValue;
|
||||
final IconData? icon;
|
||||
|
||||
Future<Option<String>?> show(BuildContext context) async {
|
||||
return showDialog(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
builder: (context) => this,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
final localizations = MaterialLocalizations.of(context);
|
||||
|
||||
final textController = useTextEditingController(
|
||||
text: initialValue?.toString(),
|
||||
);
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(title),
|
||||
icon: icon != null ? Icon(icon) : null,
|
||||
content: TextFormField(
|
||||
controller: textController,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.singleLineFormatter,
|
||||
],
|
||||
autovalidateMode: AutovalidateMode.always,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await Navigator.of(context)
|
||||
.maybePop(resetValue.map((t) => t.toString()));
|
||||
},
|
||||
child: Text(t.general.reset.toUpperCase()),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await Navigator.of(context).maybePop();
|
||||
},
|
||||
child: Text(localizations.cancelButtonLabel.toUpperCase()),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
// onConfirm(textController.value.text);
|
||||
await Navigator.of(context)
|
||||
.maybePop(some(textController.value.text));
|
||||
},
|
||||
child: Text(localizations.okButtonLabel.toUpperCase()),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
38
lib/features/settings/widgets/theme_mode_switch_button.dart
Normal file
38
lib/features/settings/widgets/theme_mode_switch_button.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ThemeModeSwitch extends StatelessWidget {
|
||||
const ThemeModeSwitch({
|
||||
super.key,
|
||||
required this.themeMode,
|
||||
required this.onChanged,
|
||||
});
|
||||
final ThemeMode themeMode;
|
||||
final ValueChanged<ThemeMode> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<bool> isSelected = <bool>[
|
||||
themeMode == ThemeMode.light,
|
||||
themeMode == ThemeMode.system,
|
||||
themeMode == ThemeMode.dark,
|
||||
];
|
||||
|
||||
return ToggleButtons(
|
||||
isSelected: isSelected,
|
||||
onPressed: (int newIndex) {
|
||||
if (newIndex == 0) {
|
||||
onChanged(ThemeMode.light);
|
||||
} else if (newIndex == 1) {
|
||||
onChanged(ThemeMode.system);
|
||||
} else {
|
||||
onChanged(ThemeMode.dark);
|
||||
}
|
||||
},
|
||||
children: const <Widget>[
|
||||
Icon(Icons.wb_sunny),
|
||||
Icon(Icons.phone_iphone),
|
||||
Icon(Icons.bedtime),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
3
lib/features/settings/widgets/widgets.dart
Normal file
3
lib/features/settings/widgets/widgets.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
export 'appearance_setting_tiles.dart';
|
||||
export 'clash_setting_tiles.dart';
|
||||
export 'network_setting_tiles.dart';
|
||||
118
lib/features/system_tray/controller/system_tray_controller.dart
Normal file
118
lib/features/system_tray/controller/system_tray_controller.dart
Normal file
@@ -0,0 +1,118 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/core/prefs/prefs.dart';
|
||||
import 'package:hiddify/domain/clash/clash.dart';
|
||||
import 'package:hiddify/domain/connectivity/connectivity.dart';
|
||||
import 'package:hiddify/features/common/clash/clash_mode.dart';
|
||||
import 'package:hiddify/features/common/connectivity/connectivity_controller.dart';
|
||||
import 'package:hiddify/gen/assets.gen.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:tray_manager/tray_manager.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
part 'system_tray_controller.g.dart';
|
||||
|
||||
// TODO: rewrite
|
||||
@Riverpod(keepAlive: true)
|
||||
class SystemTrayController extends _$SystemTrayController
|
||||
with TrayListener, AppLogger {
|
||||
@override
|
||||
Future<void> build() async {
|
||||
await trayManager.setIcon(Assets.images.logoRound);
|
||||
trayManager.addListener(this);
|
||||
ref.onDispose(() {
|
||||
loggy.debug('disposing');
|
||||
trayManager.removeListener(this);
|
||||
});
|
||||
ref.listen(
|
||||
connectivityControllerProvider,
|
||||
(_, next) async {
|
||||
connection = next;
|
||||
await _updateTray();
|
||||
},
|
||||
fireImmediately: true,
|
||||
);
|
||||
ref.listen(
|
||||
clashModeProvider.select((value) => value.valueOrNull),
|
||||
(_, next) async {
|
||||
mode = next;
|
||||
await _updateTray();
|
||||
},
|
||||
fireImmediately: true,
|
||||
);
|
||||
}
|
||||
|
||||
late ConnectionStatus connection;
|
||||
late TunnelMode? mode;
|
||||
|
||||
Future<void> _updateTray() async {
|
||||
final t = ref.watch(translationsProvider);
|
||||
final isVisible = await windowManager.isVisible();
|
||||
final trayMenu = Menu(
|
||||
items: [
|
||||
MenuItem.checkbox(
|
||||
label: t.tray.dashboard,
|
||||
checked: isVisible,
|
||||
onClick: handleClickShowApp,
|
||||
),
|
||||
if (mode != null) ...[
|
||||
MenuItem.separator(),
|
||||
...TunnelMode.values.map(
|
||||
(e) => MenuItem.checkbox(
|
||||
label: e.name,
|
||||
checked: e == mode,
|
||||
onClick: (mi) => handleClickModeItem(e, mi),
|
||||
),
|
||||
),
|
||||
],
|
||||
MenuItem.separator(),
|
||||
MenuItem.checkbox(
|
||||
label: t.tray.systemProxy,
|
||||
checked: connection.isConnected,
|
||||
disabled: connection.isSwitching,
|
||||
onClick: handleClickSetAsSystemProxy,
|
||||
),
|
||||
MenuItem.separator(),
|
||||
MenuItem(
|
||||
label: t.tray.quit,
|
||||
onClick: handleClickExitApp,
|
||||
),
|
||||
],
|
||||
);
|
||||
await trayManager.setContextMenu(trayMenu);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onTrayIconRightMouseDown() async {
|
||||
super.onTrayIconRightMouseDown();
|
||||
await trayManager.popUpContextMenu();
|
||||
}
|
||||
|
||||
Future<void> handleClickShowApp(MenuItem menuItem) async {
|
||||
if (menuItem.checked == true) {
|
||||
await windowManager.close();
|
||||
} else {
|
||||
await windowManager.show();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleClickModeItem(
|
||||
TunnelMode mode,
|
||||
MenuItem menuItem,
|
||||
) async {
|
||||
return ref
|
||||
.read(prefsControllerProvider.notifier)
|
||||
.patchClashOverrides(ClashConfigPatch(mode: some(mode)));
|
||||
}
|
||||
|
||||
Future<void> handleClickSetAsSystemProxy(MenuItem menuItem) async {
|
||||
return ref.read(connectivityControllerProvider.notifier).toggleConnection();
|
||||
}
|
||||
|
||||
Future<void> handleClickExitApp(MenuItem menuItem) async {
|
||||
exit(0);
|
||||
}
|
||||
}
|
||||
1
lib/features/system_tray/system_tray.dart
Normal file
1
lib/features/system_tray/system_tray.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'controller/system_tray_controller.dart';
|
||||
62
lib/features/wrapper/view/desktop_wrapper.dart
Normal file
62
lib/features/wrapper/view/desktop_wrapper.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
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:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:recase/recase.dart';
|
||||
|
||||
class DesktopWrapper extends HookConsumerWidget {
|
||||
const DesktopWrapper(this.navigator, {super.key});
|
||||
|
||||
final Widget navigator;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
final currentIndex = getCurrentIndex(context);
|
||||
|
||||
final destinations = [
|
||||
NavigationRailDestination(
|
||||
icon: const Icon(Icons.power_settings_new),
|
||||
label: Text(t.home.pageTitle.titleCase),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: const Icon(Icons.filter_list),
|
||||
label: Text(t.proxies.pageTitle.titleCase),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: const Icon(Icons.article),
|
||||
label: Text(t.logs.pageTitle.titleCase),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: const Icon(Icons.settings),
|
||||
label: Text(t.settings.pageTitle.titleCase),
|
||||
),
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 192,
|
||||
child: NavigationRail(
|
||||
extended: true,
|
||||
minExtendedWidth: 192,
|
||||
destinations: destinations,
|
||||
selectedIndex: currentIndex,
|
||||
onDestinationSelected: (index) => switchTab(index, context),
|
||||
trailing: const Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: TrafficChart(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: navigator),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
104
lib/features/wrapper/view/mobile_wrapper.dart
Normal file
104
lib/features/wrapper/view/mobile_wrapper.dart
Normal file
@@ -0,0 +1,104 @@
|
||||
import 'package:flutter/material.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/common.dart';
|
||||
import 'package:hiddify/gen/assets.gen.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:recase/recase.dart';
|
||||
|
||||
class MobileWrapper extends HookConsumerWidget {
|
||||
const MobileWrapper(this.navigator, {super.key});
|
||||
|
||||
final Widget navigator;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
final currentIndex = getCurrentIndex(context);
|
||||
final location = GoRouterState.of(context).location;
|
||||
|
||||
return Scaffold(
|
||||
key: RootScaffold.stateKey,
|
||||
body: navigator,
|
||||
drawer: SafeArea(
|
||||
child: Drawer(
|
||||
width: (MediaQuery.of(context).size.width * 0.88).clamp(0, 304),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Gap(16),
|
||||
DrawerTile(
|
||||
label: t.settings.pageTitle.titleCase,
|
||||
icon: Icons.settings,
|
||||
selected: location == SettingsRoute.path,
|
||||
onSelect: () => const SettingsRoute().push(context),
|
||||
),
|
||||
DrawerTile(
|
||||
label: t.logs.pageTitle.titleCase,
|
||||
icon: Icons.article,
|
||||
selected: location == LogsRoute.path,
|
||||
onSelect: () => const LogsRoute().push(context),
|
||||
),
|
||||
const Spacer(),
|
||||
Align(
|
||||
child: Column(
|
||||
children: [
|
||||
Assets.images.logo.svg(width: 64),
|
||||
const Gap(8),
|
||||
Text(
|
||||
t.general.appTitle.titleCase,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Gap(16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
destinations: [
|
||||
NavigationDestination(
|
||||
icon: const Icon(Icons.power_settings_new),
|
||||
label: t.home.pageTitle.titleCase,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: const Icon(Icons.filter_list),
|
||||
label: t.proxies.pageTitle.titleCase,
|
||||
),
|
||||
],
|
||||
selectedIndex: currentIndex > 1 ? 0 : currentIndex,
|
||||
onDestinationSelected: (index) => switchTab(index, context),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DrawerTile extends StatelessWidget {
|
||||
const DrawerTile({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.selected,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final bool selected;
|
||||
final VoidCallback onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
title: Text(label),
|
||||
leading: Icon(icon),
|
||||
selected: selected,
|
||||
onTap: selected ? () {} : onSelect,
|
||||
);
|
||||
}
|
||||
}
|
||||
2
lib/features/wrapper/wrapper.dart
Normal file
2
lib/features/wrapper/wrapper.dart
Normal file
@@ -0,0 +1,2 @@
|
||||
export 'view/desktop_wrapper.dart';
|
||||
export 'view/mobile_wrapper.dart';
|
||||
Reference in New Issue
Block a user