This commit is contained in:
problematicconsumer
2023-07-06 17:18:41 +03:30
commit b617c95f62
352 changed files with 21017 additions and 0 deletions

View File

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

View File

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

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

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

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

View 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';

View 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);
}

View File

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

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

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

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

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

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

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

View File

@@ -0,0 +1 @@
export 'home_page.dart';

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

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

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

View File

@@ -0,0 +1,3 @@
export 'active_profile_card.dart';
export 'connection_button.dart';
export 'empty_profiles_home_body.dart';

View 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);
}
}
}

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

View File

@@ -0,0 +1,2 @@
export 'logs_notifier.dart';
export 'logs_state.dart';

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

View File

@@ -0,0 +1 @@
export 'logs_page.dart';

View File

@@ -0,0 +1,2 @@
export 'profile_detail_notifier.dart';
export 'profile_detail_state.dart';

View File

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

View File

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

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

View File

@@ -0,0 +1 @@
export 'profile_detail_page.dart';

View File

@@ -0,0 +1 @@
export 'profiles_notifier.dart';

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

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

View File

@@ -0,0 +1 @@
export 'profiles_modal.dart';

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

View File

@@ -0,0 +1 @@
export 'profile_tile.dart';

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

View File

@@ -0,0 +1 @@
export 'group_with_proxies.dart';

View File

@@ -0,0 +1 @@
export 'proxies_notifier.dart';

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

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

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

View File

@@ -0,0 +1 @@
export 'proxies_page.dart';

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

View File

@@ -0,0 +1 @@
export 'proxy_tile.dart';

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

View File

@@ -0,0 +1 @@
export 'settings_page.dart';

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

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

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

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

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

View File

@@ -0,0 +1,3 @@
export 'appearance_setting_tiles.dart';
export 'clash_setting_tiles.dart';
export 'network_setting_tiles.dart';

View 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);
}
}

View File

@@ -0,0 +1 @@
export 'controller/system_tray_controller.dart';

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

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

View File

@@ -0,0 +1,2 @@
export 'view/desktop_wrapper.dart';
export 'view/mobile_wrapper.dart';