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

67
lib/utils/alerts.dart Normal file
View File

@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
enum AlertType { info, error, success }
class CustomToast extends StatelessWidget {
const CustomToast(
this.message, {
this.type = AlertType.info,
this.icon,
this.duration = const Duration(seconds: 3),
});
const CustomToast.error(
this.message, {
this.duration = const Duration(seconds: 5),
}) : type = AlertType.error,
icon = Icons.error;
const CustomToast.success(
this.message, {
this.duration = const Duration(seconds: 3),
}) : type = AlertType.success,
icon = Icons.check;
final String message;
final AlertType type;
final IconData? icon;
final Duration duration;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final color = switch (type) {
AlertType.info => null,
AlertType.error => scheme.error,
AlertType.success => scheme.tertiary,
};
return Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(4)),
color: Theme.of(context).colorScheme.surface,
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, color: color),
const SizedBox(width: 8),
],
Flexible(child: Text(message)),
],
),
);
}
void show(BuildContext context) {
FToast().init(context);
FToast().showToast(
child: this,
gravity: ToastGravity.BOTTOM,
toastDuration: duration,
);
}
}

View File

@@ -0,0 +1,75 @@
// ignore_for_file: unreachable_switch_case
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'async_mutation.freezed.dart';
// TODO: test and improve
@freezed
class AsyncMutation with _$AsyncMutation {
const AsyncMutation._();
const factory AsyncMutation.idle() = Idle;
const factory AsyncMutation.inProgress() = InProgress;
const factory AsyncMutation.fail(Object error, StackTrace stackTrace) = Fail;
const factory AsyncMutation.success() = Success;
bool get isInProgress => this is InProgress;
}
/// temporary(and hacky) way to manage async mutations
({
AsyncMutation state,
ValueChanged<Future<T>> setFuture,
ValueChanged<void Function(Object error)> setOnFailure,
}) useMutation<T>({
void Function(Object error)? initialOnFailure,
void Function()? initialOnSuccess,
}) {
final mutationUpdate = useState<Future<T>?>(null);
final mutationState = useFuture(mutationUpdate.value);
final failureCallBack =
useValueNotifier<void Function(Object error)?>(initialOnFailure);
final successCallBack = useValueNotifier<void Function()?>(initialOnSuccess);
// map AsyncSnapshot to AsyncMutation which is easier to consume
final mapped = useMemoized(
() => switch (mutationState) {
// ignore: unused_local_variable
AsyncSnapshot(:final data?) => const Success(),
AsyncSnapshot(connectionState: ConnectionState.waiting) =>
const InProgress(),
AsyncSnapshot(:final error?, :final stackTrace?) =>
Fail(error, stackTrace),
_ => const Idle(),
},
[mutationState],
);
// one-of callback in failure
useMemoized(
() {
if (mapped case Fail(:final error)) {
// if callback tries to build widget(show snackbar for example) this will prevent exceptions
WidgetsBinding.instance.addPostFrameCallback(
(_) => failureCallBack.value?.call(error),
);
}
if (mapped case Success()) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => successCallBack.value?.call(),
);
}
},
[mapped, failureCallBack.value, successCallBack.value],
);
return (
state: mapped,
setFuture: (future) => mutationUpdate.value = future,
setOnFailure: (onFailure) => failureCallBack.value = onFailure,
);
}

View File

@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
class BottomSheetPage extends Page {
const BottomSheetPage({
required this.builder,
this.fixed = false,
});
final Widget Function(ScrollController? controller) builder;
final bool fixed;
@override
Route<void> createRoute(BuildContext context) {
return ModalBottomSheetRoute(
settings: this,
isScrollControlled: !fixed,
useSafeArea: true,
showDragHandle: true,
builder: (_) {
if (!fixed) {
return DraggableScrollableSheet(
expand: false,
builder: (_, scrollController) => builder(scrollController),
);
}
return builder(null);
},
);
}
}

View File

@@ -0,0 +1,25 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
class CallbackDebouncer {
CallbackDebouncer(this._delay);
final Duration _delay;
Timer? _timer;
/// Calls the given [callback] after the given duration has passed.
void call(VoidCallback callback) {
if (_delay == Duration.zero) {
callback();
} else {
_timer?.cancel();
_timer = Timer(_delay, callback);
}
}
/// Stops any running timers and disposes this instance.
void dispose() {
_timer?.cancel();
}
}

View File

@@ -0,0 +1,31 @@
import 'package:loggy/loggy.dart';
/// application layer logger
///
/// used in notifiers and controllers
mixin AppLogger implements LoggyType {
@override
Loggy<AppLogger> get loggy => Loggy<AppLogger>('🧮 $runtimeType');
}
/// presentation layer logger
///
/// used in widgets and ui
mixin PresLogger implements LoggyType {
@override
Loggy<PresLogger> get loggy => Loggy<PresLogger>('🏰 $runtimeType');
}
/// data layer logger
///
/// used in Repositories, DAOs, Services
mixin InfraLogger implements LoggyType {
@override
Loggy<InfraLogger> get loggy => Loggy<InfraLogger>('💾 $runtimeType');
}
abstract class LoggerMixin {
LoggerMixin(this.loggy);
final Loggy loggy;
}

View File

@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hiddify/utils/text_utils.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class CustomTextFormField extends HookConsumerWidget {
const CustomTextFormField({
super.key,
required this.onChanged,
this.validator,
this.controller,
this.initialValue = '',
this.suffixIcon,
this.label,
this.hint,
this.maxLines = 1,
this.isDense = false,
this.autoValidate = false,
this.autoCorrect = false,
});
final ValueChanged<String> onChanged;
final String? Function(String? value)? validator;
final TextEditingController? controller;
final String initialValue;
final Widget? suffixIcon;
final String? label;
final String? hint;
final int maxLines;
final bool isDense;
final bool autoValidate;
final bool autoCorrect;
@override
Widget build(BuildContext context, WidgetRef ref) {
final textController =
controller ?? useTextEditingController(text: initialValue);
final effectiveConstraints =
isDense ? const BoxConstraints(maxHeight: 56) : null;
final effectiveBorder = isDense
? OutlineInputBorder(
borderRadius: BorderRadius.circular(36),
borderSide: BorderSide.none,
)
: null;
return TextFormField(
controller: textController,
textCapitalization: TextCapitalization.sentences,
maxLines: maxLines,
onChanged: onChanged,
textDirection: textController.textDirection,
validator: validator,
autovalidateMode:
autoValidate ? AutovalidateMode.always : AutovalidateMode.disabled,
autocorrect: autoCorrect,
decoration: InputDecoration(
isDense: true,
label: label != null ? Text(label!) : null,
hintText: hint,
hintStyle: Theme.of(context).textTheme.bodySmall,
constraints: effectiveConstraints,
suffixIcon: suffixIcon,
border: effectiveBorder,
enabledBorder: effectiveBorder,
errorBorder: effectiveBorder,
focusedBorder: effectiveBorder,
focusedErrorBorder: effectiveBorder,
),
);
}
}

View File

@@ -0,0 +1,33 @@
import 'package:dartx/dartx.dart';
import 'package:hiddify/utils/validators.dart';
typedef ProfileLink = ({String url, String name});
// TODO: test and improve
abstract class LinkParser {
static const protocols = ['clash', 'clashmeta'];
static ProfileLink? simple(String link) {
if (!isUrl(link)) return null;
final uri = Uri.parse(link);
final params = uri.queryParameters;
return (
url: uri
.replace(queryParameters: {})
.toString()
.removeSuffix('?')
.split('&')
.first,
name: params['name'] ?? '',
);
}
static ProfileLink? deep(String link) {
final uri = Uri.parse(link);
if (protocols.none((e) => uri.scheme == e)) return null;
if (uri.authority != 'install-config') return null;
final params = uri.queryParameters;
if (params['url'] == null) return null;
return (url: params['url']!, name: params['name'] ?? '');
}
}

View File

@@ -0,0 +1,17 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hiddify/domain/failures.dart';
part 'mutation_state.freezed.dart';
// TODO: remove
@freezed
class MutationState<F extends Failure> with _$MutationState<F> {
const MutationState._();
const factory MutationState.initial() = MutationInitial<F>;
const factory MutationState.inProgress() = MutationInProgress<F>;
const factory MutationState.failure(Failure failure) = MutationFailure<F>;
const factory MutationState.success() = MutationSuccess<F>;
bool get isInProgress => this is MutationInProgress;
}

View File

@@ -0,0 +1,22 @@
import 'dart:math';
import 'package:intl/intl.dart';
const _units = ["B", "kB", "MB", "GB", "TB"];
({String size, String unit}) formatByteSpeed(int speed) {
const base = 1024;
if (speed <= 0) return (size: "0", unit: "B/s");
final int digitGroups = (log(speed) / log(base)).round();
return (
size: NumberFormat("#,##0.#").format(speed / pow(base, digitGroups)),
unit: "${_units[digitGroups]}/s",
);
}
String formatTrafficByteSize(int consumption, int total) {
const base = 1024;
if (total <= 0) return "0 B / 0 B";
final formatter = NumberFormat("#,##0.#");
return "${formatter.format(consumption / pow(base, 3))} GB / ${formatter.format(total / pow(base, 3))} GB";
}

View File

@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
// TODO: improve
class SliverBodyPlaceholder extends HookConsumerWidget {
const SliverBodyPlaceholder(this.children, {super.key});
final List<Widget> children;
@override
Widget build(BuildContext context, WidgetRef ref) {
return SliverFillRemaining(
hasScrollBody: false,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: children,
),
);
}
}
class SliverLoadingBodyPlaceholder extends HookConsumerWidget {
const SliverLoadingBodyPlaceholder({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return const SliverFillRemaining(
hasScrollBody: false,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [CircularProgressIndicator()],
),
);
}
}
class SliverErrorBodyPlaceholder extends HookConsumerWidget {
const SliverErrorBodyPlaceholder(this.msg, {super.key, this.icon});
final String msg;
final IconData? icon;
@override
Widget build(BuildContext context, WidgetRef ref) {
return SliverFillRemaining(
hasScrollBody: false,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon ?? Icons.error),
const Gap(16),
Text(msg),
],
),
);
}
}

View File

@@ -0,0 +1,6 @@
import 'dart:io';
abstract class PlatformUtils {
static bool get isDesktop =>
Platform.isLinux || Platform.isWindows || Platform.isMacOS;
}

View File

@@ -0,0 +1,10 @@
import 'package:duration/duration.dart';
// TODO: use a better solution
String formatExpireDuration(Duration dur) {
return prettyDuration(
dur,
upperTersity: DurationTersity.day,
tersity: DurationTersity.day,
);
}

29
lib/utils/text_utils.dart Normal file
View File

@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart' as intl;
extension TextAlignX on BuildContext {
bool get isRtl => Directionality.of(this) == TextDirection.rtl;
TextAlign get textAlign {
if (isRtl) {
return TextAlign.right;
} else {
return TextAlign.left;
}
}
}
extension StringX on String {
TextDirection get textDirection {
return intl.Bidi.detectRtlDirectionality(this)
? TextDirection.rtl
: TextDirection.ltr;
}
}
extension TextEditingControllerX on TextEditingController {
TextDirection? get textDirection {
if (text.isEmpty) return null;
return text.textDirection;
}
}

14
lib/utils/utils.dart Normal file
View File

@@ -0,0 +1,14 @@
export 'alerts.dart';
export 'async_mutation.dart';
export 'bottom_sheet_page.dart';
export 'callback_debouncer.dart';
export 'custom_loggers.dart';
export 'custom_text_form_field.dart';
export 'link_parsers.dart';
export 'mutation_state.dart';
export 'number_formatters.dart';
export 'placeholders.dart';
export 'platform_utils.dart';
export 'string_formatters.dart';
export 'text_utils.dart';
export 'validators.dart';

View File

@@ -0,0 +1,9 @@
/// https://gist.github.com/dperini/729294
final _urlRegex = RegExp(
r"^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z0-9\u00a1-\uffff][a-z0-9\u00a1-\uffff_-]{0,62})?[a-z0-9\u00a1-\uffff]\.)+(?:[a-z\u00a1-\uffff]{2,}\.?))(?::\d{2,5})?(?:[/?#]\S*)?$",
);
/// https://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url/3809435#3809435
bool isUrl(String input) {
return _urlRegex.hasMatch(input.trim().toLowerCase());
}