Refactor profile tile
This commit is contained in:
@@ -24,9 +24,10 @@
|
|||||||
"subscription": {
|
"subscription": {
|
||||||
"traffic": "traffic",
|
"traffic": "traffic",
|
||||||
"updatedTimeAgo": "updated ${timeago}",
|
"updatedTimeAgo": "updated ${timeago}",
|
||||||
"remaining": "remaining",
|
"remainingDuration": "${duration} days remaining",
|
||||||
"expired": "expired",
|
"expired": "expired",
|
||||||
"noTraffic": "no traffic"
|
"noTraffic": "no traffic",
|
||||||
|
"gigaByte": "GB"
|
||||||
},
|
},
|
||||||
"add": {
|
"add": {
|
||||||
"buttonText": "add new profile",
|
"buttonText": "add new profile",
|
||||||
@@ -36,11 +37,15 @@
|
|||||||
"invalidUrlMsg": "unexpected url"
|
"invalidUrlMsg": "unexpected url"
|
||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
|
"buttonTxt": "update",
|
||||||
"failureMsg": "failed to update profile: ${reason}",
|
"failureMsg": "failed to update profile: ${reason}",
|
||||||
"successMsg": "successfully updated profile"
|
"successMsg": "successfully updated profile"
|
||||||
},
|
},
|
||||||
|
"edit": {
|
||||||
|
"buttonTxt": "edit"
|
||||||
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"buttonText": "delete",
|
"buttonTxt": "delete",
|
||||||
"confirmationMsg": "delete profile for ever? this can not be undone",
|
"confirmationMsg": "delete profile for ever? this can not be undone",
|
||||||
"successMsg": "successfully deleted profile"
|
"successMsg": "successfully deleted profile"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,9 +24,10 @@
|
|||||||
"subscription": {
|
"subscription": {
|
||||||
"traffic": "ترافیک",
|
"traffic": "ترافیک",
|
||||||
"updatedTimeAgo": "بروزرسانی شده در ${timeago}",
|
"updatedTimeAgo": "بروزرسانی شده در ${timeago}",
|
||||||
"remaining": "باقی مانده",
|
"remainingDuration": "${duration} روز باقی مانده",
|
||||||
"expired": "منقضی شده",
|
"expired": "منقضی شده",
|
||||||
"noTraffic": "پایان ترافیک"
|
"noTraffic": "پایان ترافیک",
|
||||||
|
"gigaByte": "گیگ"
|
||||||
},
|
},
|
||||||
"add": {
|
"add": {
|
||||||
"buttonText": "افزودن پروفایل جدید",
|
"buttonText": "افزودن پروفایل جدید",
|
||||||
@@ -36,11 +37,15 @@
|
|||||||
"invalidUrlMsg": "لینک نامعتبر"
|
"invalidUrlMsg": "لینک نامعتبر"
|
||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
|
"buttonTxt": "بروزرسانی",
|
||||||
"failureMsg": "در بروزرسانی پروفایل خطایی رخ داد: ${reason}",
|
"failureMsg": "در بروزرسانی پروفایل خطایی رخ داد: ${reason}",
|
||||||
"successMsg": "پروفایل با موفقیت بروزرسانی شد"
|
"successMsg": "پروفایل با موفقیت بروزرسانی شد"
|
||||||
},
|
},
|
||||||
|
"edit": {
|
||||||
|
"buttonTxt": "ویرایش"
|
||||||
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"buttonText": "حذف",
|
"buttonTxt": "حذف",
|
||||||
"confirmationMsg": "حذف پروفایل برای همیشه؟ این عمل قابل لغو نیست.",
|
"confirmationMsg": "حذف پروفایل برای همیشه؟ این عمل قابل لغو نیست.",
|
||||||
"successMsg": "پروفایل با موفقیت حذف شد"
|
"successMsg": "پروفایل با موفقیت حذف شد"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export 'add_profile_modal.dart';
|
export 'add_profile_modal.dart';
|
||||||
|
export 'confirmation_dialogs.dart';
|
||||||
export 'custom_app_bar.dart';
|
export 'custom_app_bar.dart';
|
||||||
|
export 'profile_tile.dart';
|
||||||
export 'qr_code_scanner_screen.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) {
|
switch (activeProfile) {
|
||||||
AsyncData(value: final profile?) => MultiSliver(
|
AsyncData(value: final profile?) => MultiSliver(
|
||||||
children: [
|
children: [
|
||||||
ActiveProfileCard(profile),
|
ProfileTile(profile: profile, isMain: true),
|
||||||
const SliverFillRemaining(
|
const SliverFillRemaining(
|
||||||
hasScrollBody: false,
|
hasScrollBody: false,
|
||||||
child: Padding(
|
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 'connection_button.dart';
|
||||||
export 'empty_profiles_home_body.dart';
|
export 'empty_profiles_home_body.dart';
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger {
|
|||||||
await showConfirmationDialog(
|
await showConfirmationDialog(
|
||||||
context,
|
context,
|
||||||
title:
|
title:
|
||||||
t.profile.delete.buttonText.titleCase,
|
t.profile.delete.buttonTxt.titleCase,
|
||||||
message: t.profile.delete.confirmationMsg
|
message: t.profile.delete.confirmationMsg
|
||||||
.sentenceCase,
|
.sentenceCase,
|
||||||
);
|
);
|
||||||
@@ -156,7 +156,7 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
t.profile.delete.buttonText.titleCase,
|
t.profile.delete.buttonTxt.titleCase,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: themeData
|
color: themeData
|
||||||
.colorScheme.onErrorContainer,
|
.colorScheme.onErrorContainer,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:fpdart/fpdart.dart';
|
||||||
import 'package:hiddify/data/data_providers.dart';
|
import 'package:hiddify/data/data_providers.dart';
|
||||||
import 'package:hiddify/domain/profiles/profiles.dart';
|
import 'package:hiddify/domain/profiles/profiles.dart';
|
||||||
import 'package:hiddify/utils/utils.dart';
|
import 'package:hiddify/utils/utils.dart';
|
||||||
@@ -26,6 +27,15 @@ class ProfilesNotifier extends _$ProfilesNotifier with AppLogger {
|
|||||||
}).run();
|
}).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 {
|
Future<void> deleteProfile(Profile profile) async {
|
||||||
loggy.debug('deleting profile: ${profile.name}');
|
loggy.debug('deleting profile: ${profile.name}');
|
||||||
await _profilesRepo.delete(profile.id).mapLeft(
|
await _profilesRepo.delete(profile.id).mapLeft(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
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/notifier/notifier.dart';
|
||||||
import 'package:hiddify/features/profiles/widgets/widgets.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
class ProfilesModal extends HookConsumerWidget {
|
class ProfilesModal extends HookConsumerWidget {
|
||||||
@@ -24,7 +24,7 @@ class ProfilesModal extends HookConsumerWidget {
|
|||||||
AsyncData(value: final profiles) => SliverList.builder(
|
AsyncData(value: final profiles) => SliverList.builder(
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final profile = profiles[index];
|
final profile = profiles[index];
|
||||||
return ProfileTile(profile);
|
return ProfileTile(profile: profile);
|
||||||
},
|
},
|
||||||
itemCount: profiles.length,
|
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"];
|
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) {
|
({String size, String unit}) formatByteSpeed(int speed) {
|
||||||
const base = 1024;
|
const base = 1024;
|
||||||
if (speed <= 0) return (size: "0", unit: "B/s");
|
if (speed <= 0) return (size: "0", unit: "B/s");
|
||||||
@@ -13,10 +24,3 @@ const _units = ["B", "kB", "MB", "GB", "TB"];
|
|||||||
unit: "${_units[digitGroups]}/s",
|
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 'number_formatters.dart';
|
||||||
export 'placeholders.dart';
|
export 'placeholders.dart';
|
||||||
export 'platform_utils.dart';
|
export 'platform_utils.dart';
|
||||||
export 'string_formatters.dart';
|
|
||||||
export 'text_utils.dart';
|
export 'text_utils.dart';
|
||||||
export 'validators.dart';
|
export 'validators.dart';
|
||||||
|
|||||||
Reference in New Issue
Block a user