initial
This commit is contained in:
67
lib/utils/alerts.dart
Normal file
67
lib/utils/alerts.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
75
lib/utils/async_mutation.dart
Normal file
75
lib/utils/async_mutation.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
30
lib/utils/bottom_sheet_page.dart
Normal file
30
lib/utils/bottom_sheet_page.dart
Normal 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
25
lib/utils/callback_debouncer.dart
Normal file
25
lib/utils/callback_debouncer.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
31
lib/utils/custom_loggers.dart
Normal file
31
lib/utils/custom_loggers.dart
Normal 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;
|
||||
}
|
||||
72
lib/utils/custom_text_form_field.dart
Normal file
72
lib/utils/custom_text_form_field.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
33
lib/utils/link_parsers.dart
Normal file
33
lib/utils/link_parsers.dart
Normal 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'] ?? '');
|
||||
}
|
||||
}
|
||||
17
lib/utils/mutation_state.dart
Normal file
17
lib/utils/mutation_state.dart
Normal 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;
|
||||
}
|
||||
22
lib/utils/number_formatters.dart
Normal file
22
lib/utils/number_formatters.dart
Normal 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";
|
||||
}
|
||||
58
lib/utils/placeholders.dart
Normal file
58
lib/utils/placeholders.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
6
lib/utils/platform_utils.dart
Normal file
6
lib/utils/platform_utils.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
import 'dart:io';
|
||||
|
||||
abstract class PlatformUtils {
|
||||
static bool get isDesktop =>
|
||||
Platform.isLinux || Platform.isWindows || Platform.isMacOS;
|
||||
}
|
||||
10
lib/utils/string_formatters.dart
Normal file
10
lib/utils/string_formatters.dart
Normal 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
29
lib/utils/text_utils.dart
Normal 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
14
lib/utils/utils.dart
Normal 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';
|
||||
9
lib/utils/validators.dart
Normal file
9
lib/utils/validators.dart
Normal 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());
|
||||
}
|
||||
Reference in New Issue
Block a user