Refactor profile tile
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
export 'add_profile_modal.dart';
|
||||
export 'confirmation_dialogs.dart';
|
||||
export 'custom_app_bar.dart';
|
||||
export 'profile_tile.dart';
|
||||
export 'qr_code_scanner_screen.dart';
|
||||
export 'remaining_traffic_indicator.dart';
|
||||
|
||||
345
lib/features/common/profile_tile.dart
Normal file
345
lib/features/common/profile_tile.dart
Normal file
@@ -0,0 +1,345 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/core/locale/locale.dart';
|
||||
import 'package:hiddify/core/router/routes/routes.dart';
|
||||
import 'package:hiddify/domain/failures.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.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:percent_indicator/percent_indicator.dart';
|
||||
import 'package:recase/recase.dart';
|
||||
|
||||
class ProfileTile extends HookConsumerWidget {
|
||||
const ProfileTile({
|
||||
super.key,
|
||||
required this.profile,
|
||||
this.isMain = false,
|
||||
});
|
||||
|
||||
final Profile profile;
|
||||
|
||||
/// home screen active profile card
|
||||
final bool isMain;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final selectActiveMutation = useMutation(
|
||||
initialOnFailure: (err) {
|
||||
CustomToast.error(t.presentError(err)).show(context);
|
||||
},
|
||||
);
|
||||
|
||||
final subInfo = profile.subInfo;
|
||||
|
||||
final effectiveMargin = isMain
|
||||
? const EdgeInsets.symmetric(horizontal: 16, vertical: 8)
|
||||
: const EdgeInsets.only(left: 12, right: 12, bottom: 12);
|
||||
final double effectiveElevation = profile.active ? 12 : 4;
|
||||
final effectiveOutlineColor =
|
||||
profile.active ? theme.colorScheme.outlineVariant : Colors.transparent;
|
||||
|
||||
return Card(
|
||||
margin: effectiveMargin,
|
||||
elevation: effectiveElevation,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(color: effectiveOutlineColor),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
shadowColor: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: isMain
|
||||
? null
|
||||
: () {
|
||||
if (selectActiveMutation.state.isInProgress) return;
|
||||
if (profile.active) return;
|
||||
selectActiveMutation.setFuture(
|
||||
ref
|
||||
.read(profilesNotifierProvider.notifier)
|
||||
.selectActiveProfile(profile.id),
|
||||
);
|
||||
},
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 48,
|
||||
child: ProfileActionButton(profile, !isMain),
|
||||
),
|
||||
VerticalDivider(
|
||||
width: 1,
|
||||
color: effectiveOutlineColor,
|
||||
),
|
||||
Flexible(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (isMain)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Colors.transparent,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () => const ProfilesRoute().go(context),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
profile.name,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
const Icon(Icons.arrow_drop_down),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
profile.name,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
if (subInfo?.isValid ?? false) ...[
|
||||
const Gap(4),
|
||||
RemainingTrafficIndicator(subInfo!.ratio),
|
||||
const Gap(4),
|
||||
ProfileSubscriptionInfo(subInfo),
|
||||
const Gap(4),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ProfileActionButton extends HookConsumerWidget {
|
||||
const ProfileActionButton(this.profile, this.showAllActions, {super.key});
|
||||
|
||||
final Profile profile;
|
||||
final bool showAllActions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
final updateProfileMutation = useMutation(
|
||||
initialOnFailure: (err) {
|
||||
CustomToast.error(t.presentError(err)).show(context);
|
||||
},
|
||||
initialOnSuccess: () =>
|
||||
CustomToast.success(t.profile.update.successMsg).show(context),
|
||||
);
|
||||
|
||||
if (!showAllActions) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
if (updateProfileMutation.state.isInProgress) {
|
||||
return;
|
||||
}
|
||||
updateProfileMutation.setFuture(
|
||||
ref.read(profilesNotifierProvider.notifier).updateProfile(profile),
|
||||
);
|
||||
},
|
||||
child: const Icon(Icons.refresh),
|
||||
);
|
||||
}
|
||||
return ProfileActionsMenu(
|
||||
profile,
|
||||
(context, controller, child) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
if (controller.isOpen) {
|
||||
controller.close();
|
||||
} else {
|
||||
controller.open();
|
||||
}
|
||||
},
|
||||
child: const Icon(Icons.more_vert),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ProfileActionsMenu extends HookConsumerWidget {
|
||||
const ProfileActionsMenu(this.profile, this.builder, {super.key, this.child});
|
||||
|
||||
final Profile profile;
|
||||
final MenuAnchorChildBuilder builder;
|
||||
final Widget? child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
|
||||
final updateProfileMutation = useMutation(
|
||||
initialOnFailure: (err) {
|
||||
CustomToast.error(t.presentError(err)).show(context);
|
||||
},
|
||||
initialOnSuccess: () =>
|
||||
CustomToast.success(t.profile.update.successMsg).show(context),
|
||||
);
|
||||
final deleteProfileMutation = useMutation(
|
||||
initialOnFailure: (err) {
|
||||
CustomToast.error(t.presentError(err)).show(context);
|
||||
},
|
||||
);
|
||||
|
||||
return MenuAnchor(
|
||||
builder: builder,
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(Icons.refresh),
|
||||
child: Text(t.profile.update.buttonTxt.titleCase),
|
||||
onPressed: () {
|
||||
if (updateProfileMutation.state.isInProgress) {
|
||||
return;
|
||||
}
|
||||
updateProfileMutation.setFuture(
|
||||
ref
|
||||
.read(profilesNotifierProvider.notifier)
|
||||
.updateProfile(profile),
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(Icons.edit),
|
||||
child: Text(t.profile.edit.buttonTxt.titleCase),
|
||||
onPressed: () async {
|
||||
await ProfileDetailsRoute(profile.id).push(context);
|
||||
},
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(Icons.delete),
|
||||
child: Text(t.profile.delete.buttonTxt.titleCase),
|
||||
onPressed: () async {
|
||||
if (deleteProfileMutation.state.isInProgress) {
|
||||
return;
|
||||
}
|
||||
final deleteConfirmed = await showConfirmationDialog(
|
||||
context,
|
||||
title: t.profile.delete.buttonTxt.titleCase,
|
||||
message: t.profile.delete.confirmationMsg.sentenceCase,
|
||||
);
|
||||
if (deleteConfirmed) {
|
||||
deleteProfileMutation.setFuture(
|
||||
ref
|
||||
.read(profilesNotifierProvider.notifier)
|
||||
.deleteProfile(profile),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO add support url
|
||||
class ProfileSubscriptionInfo extends HookConsumerWidget {
|
||||
const ProfileSubscriptionInfo(this.subInfo, {super.key});
|
||||
|
||||
final SubscriptionInfo subInfo;
|
||||
|
||||
(String, Color?) remainingText(TranslationsEn t, ThemeData theme) {
|
||||
if (subInfo.isExpired) {
|
||||
return (t.profile.subscription.expired, theme.colorScheme.error);
|
||||
} else if (subInfo.ratio >= 1) {
|
||||
return (t.profile.subscription.noTraffic, theme.colorScheme.error);
|
||||
} else if (subInfo.remaining.inDays > 365) {
|
||||
return (t.profile.subscription.remainingDuration(duration: "∞"), null);
|
||||
} else {
|
||||
return (
|
||||
t.profile.subscription
|
||||
.remainingDuration(duration: subInfo.remaining.inDays),
|
||||
null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final remaining = remainingText(t, theme);
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (subInfo.total != null)
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: formatByte(subInfo.consumption, unit: 3).size),
|
||||
const TextSpan(text: " / "),
|
||||
TextSpan(text: formatByte(subInfo.total!, unit: 3).size),
|
||||
const TextSpan(text: " "),
|
||||
TextSpan(text: t.profile.subscription.gigaByte),
|
||||
],
|
||||
),
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
Text(
|
||||
remaining.$1,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: remaining.$2),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ class HomePage extends HookConsumerWidget {
|
||||
switch (activeProfile) {
|
||||
AsyncData(value: final profile?) => MultiSliver(
|
||||
children: [
|
||||
ActiveProfileCard(profile),
|
||||
ProfileTile(profile: profile, isMain: true),
|
||||
const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Padding(
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
export 'active_profile_card.dart';
|
||||
export 'connection_button.dart';
|
||||
export 'empty_profiles_home_body.dart';
|
||||
|
||||
@@ -142,7 +142,7 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger {
|
||||
await showConfirmationDialog(
|
||||
context,
|
||||
title:
|
||||
t.profile.delete.buttonText.titleCase,
|
||||
t.profile.delete.buttonTxt.titleCase,
|
||||
message: t.profile.delete.confirmationMsg
|
||||
.sentenceCase,
|
||||
);
|
||||
@@ -156,7 +156,7 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger {
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
t.profile.delete.buttonText.titleCase,
|
||||
t.profile.delete.buttonTxt.titleCase,
|
||||
style: TextStyle(
|
||||
color: themeData
|
||||
.colorScheme.onErrorContainer,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
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';
|
||||
@@ -26,6 +27,15 @@ class ProfilesNotifier extends _$ProfilesNotifier with AppLogger {
|
||||
}).run();
|
||||
}
|
||||
|
||||
Future<Unit?> updateProfile(Profile profile) async {
|
||||
loggy.debug("updating profile");
|
||||
return ref
|
||||
.read(profilesRepositoryProvider)
|
||||
.update(profile)
|
||||
.getOrElse((l) => throw l)
|
||||
.run();
|
||||
}
|
||||
|
||||
Future<void> deleteProfile(Profile profile) async {
|
||||
loggy.debug('deleting profile: ${profile.name}');
|
||||
await _profilesRepo.delete(profile.id).mapLeft(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hiddify/features/common/common.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 {
|
||||
@@ -24,7 +24,7 @@ class ProfilesModal extends HookConsumerWidget {
|
||||
AsyncData(value: final profiles) => SliverList.builder(
|
||||
itemBuilder: (context, index) {
|
||||
final profile = profiles[index];
|
||||
return ProfileTile(profile);
|
||||
return ProfileTile(profile: profile);
|
||||
},
|
||||
itemCount: profiles.length,
|
||||
),
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/core/router/router.dart';
|
||||
import 'package:hiddify/domain/failures.dart';
|
||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||
import 'package:hiddify/features/common/common.dart';
|
||||
import 'package:hiddify/features/common/confirmation_dialogs.dart';
|
||||
import 'package:hiddify/features/profiles/notifier/notifier.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:recase/recase.dart';
|
||||
import 'package:timeago/timeago.dart' as timeago;
|
||||
|
||||
class ProfileTile extends HookConsumerWidget {
|
||||
const ProfileTile(this.profile, {super.key});
|
||||
|
||||
final Profile profile;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
final subInfo = profile.subInfo;
|
||||
|
||||
final themeData = Theme.of(context);
|
||||
|
||||
final selectActiveMutation = useMutation(
|
||||
initialOnFailure: (err) {
|
||||
CustomToast.error(t.presentError(err)).show(context);
|
||||
},
|
||||
);
|
||||
final deleteProfileMutation = useMutation(
|
||||
initialOnFailure: (err) {
|
||||
CustomToast.error(t.presentError(err)).show(context);
|
||||
},
|
||||
);
|
||||
|
||||
return Card(
|
||||
elevation: 6,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
shadowColor: Colors.transparent,
|
||||
color: profile.active ? themeData.colorScheme.tertiaryContainer : null,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
if (profile.active || selectActiveMutation.state.isInProgress) return;
|
||||
selectActiveMutation.setFuture(
|
||||
ref
|
||||
.read(profilesNotifierProvider.notifier)
|
||||
.selectActiveProfile(profile.id),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text.rich(
|
||||
overflow: TextOverflow.ellipsis,
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: profile.name,
|
||||
style: themeData.textTheme.titleMedium,
|
||||
),
|
||||
const TextSpan(text: " • "),
|
||||
TextSpan(
|
||||
text: t.profile.subscription.updatedTimeAgo(
|
||||
timeago: timeago.format(profile.lastUpdate),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const Gap(12),
|
||||
SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 18,
|
||||
onPressed: () async {
|
||||
// await context.push(Routes.profile(profile.id).path);
|
||||
// TODO: temp
|
||||
await ProfileDetailsRoute(profile.id).push(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Gap(12),
|
||||
SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.delete_forever),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
iconSize: 18,
|
||||
onPressed: () async {
|
||||
if (deleteProfileMutation.state.isInProgress) {
|
||||
return;
|
||||
}
|
||||
final deleteConfirmed =
|
||||
await showConfirmationDialog(
|
||||
context,
|
||||
title: t.profile.delete.buttonText.titleCase,
|
||||
message:
|
||||
t.profile.delete.confirmationMsg.sentenceCase,
|
||||
);
|
||||
if (deleteConfirmed) {
|
||||
deleteProfileMutation.setFuture(
|
||||
ref
|
||||
.read(profilesNotifierProvider.notifier)
|
||||
.deleteProfile(profile),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (subInfo?.isValid ?? false) ...[
|
||||
const Gap(2),
|
||||
Row(
|
||||
children: [
|
||||
if (subInfo!.isExpired)
|
||||
Text(
|
||||
t.profile.subscription.expired,
|
||||
style: themeData.textTheme.titleSmall
|
||||
?.copyWith(color: themeData.colorScheme.error),
|
||||
)
|
||||
else if (subInfo.ratio >= 1)
|
||||
Text(
|
||||
t.profile.subscription.noTraffic,
|
||||
style: themeData.textTheme.titleSmall?.copyWith(
|
||||
color: themeData.colorScheme.error,
|
||||
),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
formatExpireDuration(subInfo.remaining),
|
||||
style: themeData.textTheme.titleSmall,
|
||||
),
|
||||
Text(
|
||||
t.profile.subscription.remaining,
|
||||
style: themeData.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(16),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
formatTrafficByteSize(
|
||||
subInfo.consumption,
|
||||
subInfo.total!,
|
||||
),
|
||||
style: themeData.textTheme.titleMedium,
|
||||
),
|
||||
RemainingTrafficIndicator(subInfo.ratio),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export 'profile_tile.dart';
|
||||
@@ -4,6 +4,17 @@ import 'package:intl/intl.dart';
|
||||
|
||||
const _units = ["B", "kB", "MB", "GB", "TB"];
|
||||
|
||||
({String size, String unit}) formatByte(int input, {int? unit}) {
|
||||
const base = 1024;
|
||||
if (input <= 0) return (size: "0", unit: _units[unit ?? 0]);
|
||||
final int digitGroups = unit ?? (log(input) / log(base)).round();
|
||||
return (
|
||||
size: NumberFormat("#,##0.#").format(input / pow(base, digitGroups)),
|
||||
unit: _units[digitGroups],
|
||||
);
|
||||
}
|
||||
|
||||
// TODO remove
|
||||
({String size, String unit}) formatByteSpeed(int speed) {
|
||||
const base = 1024;
|
||||
if (speed <= 0) return (size: "0", unit: "B/s");
|
||||
@@ -13,10 +24,3 @@ const _units = ["B", "kB", "MB", "GB", "TB"];
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import 'package:duration/duration.dart';
|
||||
|
||||
// TODO: use a better solution
|
||||
String formatExpireDuration(Duration dur) {
|
||||
return prettyDuration(
|
||||
dur,
|
||||
upperTersity: DurationTersity.day,
|
||||
tersity: DurationTersity.day,
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,5 @@ 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';
|
||||
|
||||
Reference in New Issue
Block a user