diff --git a/lib/core/widget/shimmer_skeleton.dart b/lib/core/widget/shimmer_skeleton.dart new file mode 100644 index 00000000..09d06abb --- /dev/null +++ b/lib/core/widget/shimmer_skeleton.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:hiddify/core/widget/skeleton_widget.dart'; + +class ShimmerSkeleton extends StatelessWidget { + const ShimmerSkeleton({ + this.width, + this.height, + this.widthFactor, + this.heightFactor, + this.color, + this.duration = const Duration(seconds: 1), + super.key, + }); + + final double? width; + final double? height; + final double? widthFactor; + final double? heightFactor; + final Color? color; + final Duration duration; + + @override + Widget build(BuildContext context) { + return Skeleton( + width: width, + height: height, + widthFactor: widthFactor, + heightFactor: heightFactor, + ) + .animate( + onPlay: (controller) => controller.loop(), + ) + .shimmer( + duration: duration, + angle: 45, + color: color ?? Theme.of(context).colorScheme.secondary, + ); + } +} diff --git a/lib/features/proxy/active/active_proxy_delay_indicator.dart b/lib/features/proxy/active/active_proxy_delay_indicator.dart index 605d8188..66d4ec9d 100644 --- a/lib/features/proxy/active/active_proxy_delay_indicator.dart +++ b/lib/features/proxy/active/active_proxy_delay_indicator.dart @@ -1,9 +1,8 @@ import 'package:fluentui_system_icons/fluentui_system_icons.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_animate/flutter_animate.dart'; import 'package:gap/gap.dart'; import 'package:hiddify/core/widget/animated_visibility.dart'; -import 'package:hiddify/core/widget/skeleton_widget.dart'; +import 'package:hiddify/core/widget/shimmer_skeleton.dart'; import 'package:hiddify/features/proxy/active/active_proxy_notifier.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -52,18 +51,7 @@ class ActiveProxyDelayIndicator extends HookConsumerWidget { ), ) else - const Skeleton( - height: 18, - width: 48, - ) - .animate( - onPlay: (controller) => controller.loop(), - ) - .shimmer( - duration: 1000.ms, - angle: 45, - color: Theme.of(context).colorScheme.secondary, - ), + const ShimmerSkeleton(width: 48, height: 18), ], ), ), diff --git a/lib/features/proxy/active/active_proxy_footer.dart b/lib/features/proxy/active/active_proxy_footer.dart index 1714360a..cdcf407e 100644 --- a/lib/features/proxy/active/active_proxy_footer.dart +++ b/lib/features/proxy/active/active_proxy_footer.dart @@ -1,13 +1,12 @@ -import 'package:circle_flags/circle_flags.dart'; import 'package:dartx/dartx.dart'; import 'package:fluentui_system_icons/fluentui_system_icons.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_animate/flutter_animate.dart'; import 'package:gap/gap.dart'; import 'package:hiddify/core/localization/translations.dart'; import 'package:hiddify/core/widget/animated_visibility.dart'; -import 'package:hiddify/core/widget/skeleton_widget.dart'; +import 'package:hiddify/core/widget/shimmer_skeleton.dart'; import 'package:hiddify/features/proxy/active/active_proxy_notifier.dart'; +import 'package:hiddify/features/proxy/active/ip_widget.dart'; import 'package:hiddify/features/stats/notifier/stats_notifier.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -35,23 +34,44 @@ class ActiveProxyFooter extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ _InfoProp( - icon: FluentIcons.arrow_routing_24_regular, + icon: FluentIcons.arrow_routing_20_regular, text: info.proxy.selectedName.isNotNullOrBlank ? info.proxy.selectedName! : info.proxy.name, ), const Gap(8), switch (info.ipInfo) { - AsyncData(value: final ip?) => _InfoProp.flag( - countryCode: ip.countryCode, - text: ip.ip, + AsyncData(value: final ipInfo?) => Row( + children: [ + IPCountryFlag(countryCode: ipInfo.countryCode), + const Gap(8), + IPText( + ip: ipInfo.ip, + onLongPress: () async { + ref + .read( + activeProxyNotifierProvider.notifier, + ) + .refreshIpInfo(); + }, + ), + ], ), AsyncError() => _InfoProp( icon: FluentIcons.error_circle_20_regular, text: t.general.unknown, ), - _ => _InfoProp.loading( - icon: FluentIcons.question_circle_24_regular, + _ => const Row( + children: [ + Icon(FluentIcons.question_circle_20_regular), + Gap(8), + Flexible( + child: ShimmerSkeleton( + height: 16, + widthFactor: 1, + ), + ), + ], ), }, ], @@ -66,12 +86,12 @@ class ActiveProxyFooter extends HookConsumerWidget { children: [ _InfoProp( icon: FluentIcons - .arrow_bidirectional_up_down_24_regular, + .arrow_bidirectional_up_down_20_regular, text: (stats?.downlinkTotal ?? 0).size(), ), const Gap(8), _InfoProp( - icon: FluentIcons.arrow_download_24_regular, + icon: FluentIcons.arrow_download_20_regular, text: (stats?.downlink ?? 0).speed(), ), ], @@ -88,97 +108,28 @@ class ActiveProxyFooter extends HookConsumerWidget { } class _InfoProp extends StatelessWidget { - _InfoProp({ - required IconData icon, - required String text, - }) : icon = Icon(icon), - child = IPWidget(text), - isLoading = false; + const _InfoProp({ + required this.icon, + required this.text, + }); - _InfoProp.flag({ - required String countryCode, - required String text, - }) : icon = Container( - width: 24, - height: 24, - padding: const EdgeInsets.all(2), - child: CircleFlag(countryCode), - ), - child = IPWidget(text), - isLoading = false; - - _InfoProp.loading({ - required IconData icon, - }) : icon = Icon(icon), - child = const SizedBox(), - isLoading = true; - - final Widget icon; - final Widget child; - final bool isLoading; + final IconData icon; + final String text; @override Widget build(BuildContext context) { return Row( children: [ - icon, + Icon(icon), const Gap(8), - if (isLoading) - Flexible( - child: const Skeleton(height: 16, widthFactor: 1) - .animate( - onPlay: (controller) => controller.loop(), - ) - .shimmer( - duration: 1000.ms, - angle: 45, - color: Theme.of(context).colorScheme.secondary, - ), - ) - else - Flexible(child: child), + Flexible( + child: Text( + text, + style: Theme.of(context).textTheme.labelMedium, + overflow: TextOverflow.ellipsis, + ), + ), ], ); } } - -class IPWidget extends StatefulWidget { - final String text; - final TextStyle? style; - IPWidget(this.text, {this.style}) : super(key: UniqueKey()); - - @override - _IPWidgetState createState() => _IPWidgetState(); -} - -class _IPWidgetState extends State { - bool isFullIPVisible = true; - - void toggleVisibility() { - setState(() { - isFullIPVisible = !isFullIPVisible; - }); - } - - static String _replaceMiddlePart(String ip) { - RegExp regex = RegExp( - r'^([\da-f]+([:.]))([\da-f:.]*)([:.][\da-f]+)$', - caseSensitive: false, - ); - return ip.replaceAllMapped(regex, (match) { - return '${match[1]} ░ ${match[2]} ░ ${match[4]}'; - }); - } - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: toggleVisibility, - child: Text( - isFullIPVisible ? _replaceMiddlePart(widget.text) : widget.text, - overflow: TextOverflow.ellipsis, - style: widget.style, - ), - ); - } -} diff --git a/lib/features/proxy/active/active_proxy_sidebar_card.dart b/lib/features/proxy/active/active_proxy_sidebar_card.dart index a5ffd4c2..d1082aba 100644 --- a/lib/features/proxy/active/active_proxy_sidebar_card.dart +++ b/lib/features/proxy/active/active_proxy_sidebar_card.dart @@ -1,13 +1,11 @@ -import 'package:circle_flags/circle_flags.dart'; import 'package:dartx/dartx.dart'; import 'package:fluentui_system_icons/fluentui_system_icons.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_animate/flutter_animate.dart'; import 'package:gap/gap.dart'; import 'package:hiddify/core/localization/translations.dart'; -import 'package:hiddify/core/widget/skeleton_widget.dart'; -import 'package:hiddify/features/proxy/active/active_proxy_footer.dart'; +import 'package:hiddify/core/widget/shimmer_skeleton.dart'; import 'package:hiddify/features/proxy/active/active_proxy_notifier.dart'; +import 'package:hiddify/features/proxy/active/ip_widget.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; class ActiveProxySideBarCard extends HookConsumerWidget { @@ -31,7 +29,11 @@ class ActiveProxySideBarCard extends HookConsumerWidget { final asyncState = ref.watch(activeProxyNotifierProvider); Widget propText(String txt) { - return IPWidget(txt, style: theme.textTheme.bodySmall); + return Text( + txt, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall, + ); } return Theme( @@ -68,8 +70,19 @@ class ActiveProxySideBarCard extends HookConsumerWidget { switch (value.ipInfo) { case AsyncData(value: final ipInfo?): return buildProp( - CircleFlag(ipInfo.countryCode, size: 12), - propText(ipInfo.ip), + IPCountryFlag( + countryCode: ipInfo.countryCode, size: 16), + IPText( + ip: ipInfo.ip, + onLongPress: () async { + ref + .read( + activeProxyNotifierProvider.notifier, + ) + .refreshIpInfo(); + }, + constrained: true, + ), ); case AsyncError(): return buildProp( @@ -79,15 +92,7 @@ class ActiveProxySideBarCard extends HookConsumerWidget { case AsyncLoading(): return buildProp( const Icon(FluentIcons.question_circle_20_regular), - const Skeleton(height: 14, widthFactor: .85) - .animate( - onPlay: (controller) => controller.loop(), - ) - .shimmer( - duration: 1000.ms, - angle: 45, - color: Theme.of(context).colorScheme.secondary, - ), + const ShimmerSkeleton(widthFactor: .85, height: 14), ); } } diff --git a/lib/features/proxy/active/ip_widget.dart b/lib/features/proxy/active/ip_widget.dart new file mode 100644 index 00000000..16b4d2d6 --- /dev/null +++ b/lib/features/proxy/active/ip_widget.dart @@ -0,0 +1,94 @@ +import 'package:circle_flags/circle_flags.dart'; +import 'package:fluentui_system_icons/fluentui_system_icons.dart'; +import 'package:flutter/material.dart'; +import 'package:hiddify/utils/riverpod_utils.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +final _showIp = StateProvider.autoDispose((ref) { + ref.disposeDelay(const Duration(seconds: 20)); + return false; +}); + +class IPText extends HookConsumerWidget { + const IPText({ + required this.ip, + required this.onLongPress, + this.constrained = false, + super.key, + }); + + final String ip; + final VoidCallback onLongPress; + final bool constrained; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isVisible = ref.watch(_showIp); + final textTheme = Theme.of(context).textTheme; + + return InkWell( + onTap: () { + ref.read(_showIp.notifier).state = !isVisible; + }, + onLongPress: onLongPress, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: AnimatedCrossFade( + firstChild: Text( + ip, + style: textTheme.labelMedium, + overflow: TextOverflow.ellipsis, + ), + secondChild: Padding( + padding: constrained + ? EdgeInsets.zero + : const EdgeInsetsDirectional.only(end: 48), + child: Text( + "*.*.*.*", + style: constrained ? textTheme.labelMedium : textTheme.labelLarge, + overflow: TextOverflow.ellipsis, + ), + ), + crossFadeState: + isVisible ? CrossFadeState.showFirst : CrossFadeState.showSecond, + duration: const Duration(milliseconds: 200), + ), + ), + ); + } +} + +class IPCountryFlag extends HookConsumerWidget { + const IPCountryFlag({required this.countryCode, this.size = 24, super.key}); + + final String countryCode; + final double size; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isVisible = ref.watch(_showIp); + + return InkWell( + onTap: () { + ref.read(_showIp.notifier).state = !isVisible; + }, + borderRadius: BorderRadius.circular(12), + child: Container( + width: size, + height: size, + padding: const EdgeInsets.all(2), + child: Center( + child: AnimatedCrossFade( + firstChild: CircleFlag(countryCode), + secondChild: Icon(FluentIcons.eye_off_24_regular, size: size * .8), + crossFadeState: isVisible + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + duration: const Duration(milliseconds: 200), + ), + ), + ), + ); + } +}