diff --git a/assets/translations/strings.i18n.json b/assets/translations/strings.i18n.json index c14409b8..d5ec3150 100644 --- a/assets/translations/strings.i18n.json +++ b/assets/translations/strings.i18n.json @@ -5,7 +5,9 @@ "toggle": { "enabled": "enabled", "disabled": "disabled" - } + }, + "sort": "sort", + "sortBy": "sort by" }, "home": { "pageTitle": "home", @@ -29,8 +31,13 @@ "noTraffic": "no traffic", "gigaByte": "GB" }, + "sortBy": { + "lastUpdate": "Recently updated", + "name": "by Name" + }, "add": { "buttonText": "add new profile", + "shortBtnTxt": "add new", "fromClipboard": "add from clipboard", "scanQr": "Scan QR code", "manually": "add manually", diff --git a/assets/translations/strings_fa.i18n.json b/assets/translations/strings_fa.i18n.json index 8b0a48f3..5661f8e3 100644 --- a/assets/translations/strings_fa.i18n.json +++ b/assets/translations/strings_fa.i18n.json @@ -5,7 +5,9 @@ "toggle": { "enabled": "فعال", "disabled": "غیر فعال" - } + }, + "sort": "مرتب‌سازی", + "sortBy": "مرتب‌سازی براساس" }, "home": { "pageTitle": "خانه", @@ -29,8 +31,13 @@ "noTraffic": "پایان ترافیک", "gigaByte": "گیگ" }, + "sortBy": { + "lastUpdate": "اخیرا بروز شده", + "name": "براساس نام" + }, "add": { "buttonText": "افزودن پروفایل جدید", + "shortBtnTxt": "ایجاد", "fromClipboard": "افزودن از کلیپ‌بورد", "scanQr": "اسکن QR کد", "manually": "افزودن دستی", diff --git a/lib/data/local/dao/profiles_dao.dart b/lib/data/local/dao/profiles_dao.dart index f9198a97..fd3218c8 100644 --- a/lib/data/local/dao/profiles_dao.dart +++ b/lib/data/local/dao/profiles_dao.dart @@ -2,11 +2,17 @@ import 'package:drift/drift.dart'; import 'package:hiddify/data/local/data_mappers.dart'; import 'package:hiddify/data/local/database.dart'; import 'package:hiddify/data/local/tables.dart'; +import 'package:hiddify/domain/enums.dart'; import 'package:hiddify/domain/profiles/profiles.dart'; import 'package:hiddify/utils/utils.dart'; part 'profiles_dao.g.dart'; +Map orderMap = { + SortMode.ascending: OrderingMode.asc, + SortMode.descending: OrderingMode.desc +}; + @DriftAccessor(tables: [ProfileEntries]) class ProfilesDao extends DatabaseAccessor with _$ProfilesDaoMixin, InfraLogger { @@ -31,10 +37,24 @@ class ProfilesDao extends DatabaseAccessor .watchSingle(); } - Stream> watchAll() { + Stream> watchAll({ + ProfilesSort sort = ProfilesSort.lastUpdate, + SortMode mode = SortMode.ascending, + }) { return (profileEntries.select() ..orderBy( - [(tbl) => OrderingTerm.desc(tbl.active)], + [ + switch (sort) { + ProfilesSort.name => (tbl) => OrderingTerm( + expression: tbl.name, + mode: orderMap[mode]!, + ), + _ => (tbl) => OrderingTerm( + expression: tbl.lastUpdate, + mode: orderMap[mode]!, + ), + } + ], )) .map(ProfileMapper.fromEntry) .watch(); diff --git a/lib/data/repository/profiles_repository_impl.dart b/lib/data/repository/profiles_repository_impl.dart index f3176ca6..05d5a0a5 100644 --- a/lib/data/repository/profiles_repository_impl.dart +++ b/lib/data/repository/profiles_repository_impl.dart @@ -5,6 +5,7 @@ import 'package:fpdart/fpdart.dart'; import 'package:hiddify/data/local/dao/dao.dart'; import 'package:hiddify/data/repository/exception_handlers.dart'; import 'package:hiddify/domain/clash/clash.dart'; +import 'package:hiddify/domain/enums.dart'; import 'package:hiddify/domain/profiles/profiles.dart'; import 'package:hiddify/services/files_editor_service.dart'; import 'package:hiddify/utils/utils.dart'; @@ -50,9 +51,12 @@ class ProfilesRepositoryImpl } @override - Stream>> watchAll() { + Stream>> watchAll({ + ProfilesSort sort = ProfilesSort.lastUpdate, + SortMode mode = SortMode.ascending, + }) { return profilesDao - .watchAll() + .watchAll(sort: sort, mode: mode) .handleExceptions(ProfileUnexpectedFailure.new); } diff --git a/lib/domain/enums.dart b/lib/domain/enums.dart new file mode 100644 index 00000000..e5ce3ee4 --- /dev/null +++ b/lib/domain/enums.dart @@ -0,0 +1 @@ +enum SortMode { ascending, descending } diff --git a/lib/domain/profiles/profile_enums.dart b/lib/domain/profiles/profile_enums.dart new file mode 100644 index 00000000..88ae0eae --- /dev/null +++ b/lib/domain/profiles/profile_enums.dart @@ -0,0 +1,13 @@ +import 'package:hiddify/core/locale/locale.dart'; + +enum ProfilesSort { + lastUpdate, + name; + + String present(TranslationsEn t) { + return switch (this) { + lastUpdate => t.profile.sortBy.lastUpdate, + name => t.profile.sortBy.name, + }; + } +} diff --git a/lib/domain/profiles/profiles.dart b/lib/domain/profiles/profiles.dart index 4ef76748..fb63afe8 100644 --- a/lib/domain/profiles/profiles.dart +++ b/lib/domain/profiles/profiles.dart @@ -1,3 +1,4 @@ export 'profile.dart'; +export 'profile_enums.dart'; export 'profiles_failure.dart'; export 'profiles_repository.dart'; diff --git a/lib/domain/profiles/profiles_repository.dart b/lib/domain/profiles/profiles_repository.dart index ca9dab72..2c719862 100644 --- a/lib/domain/profiles/profiles_repository.dart +++ b/lib/domain/profiles/profiles_repository.dart @@ -1,4 +1,5 @@ import 'package:fpdart/fpdart.dart'; +import 'package:hiddify/domain/enums.dart'; import 'package:hiddify/domain/profiles/profiles.dart'; abstract class ProfilesRepository { @@ -8,7 +9,10 @@ abstract class ProfilesRepository { Stream> watchHasAnyProfile(); - Stream>> watchAll(); + Stream>> watchAll({ + ProfilesSort sort = ProfilesSort.lastUpdate, + SortMode mode = SortMode.ascending, + }); TaskEither addByUrl( String url, { diff --git a/lib/features/profiles/notifier/profiles_notifier.dart b/lib/features/profiles/notifier/profiles_notifier.dart index 7a862b43..4db60c13 100644 --- a/lib/features/profiles/notifier/profiles_notifier.dart +++ b/lib/features/profiles/notifier/profiles_notifier.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:fpdart/fpdart.dart'; import 'package:hiddify/data/data_providers.dart'; +import 'package:hiddify/domain/enums.dart'; import 'package:hiddify/domain/profiles/profiles.dart'; import 'package:hiddify/features/common/active_profile/active_profile_notifier.dart'; import 'package:hiddify/utils/utils.dart'; @@ -9,12 +10,31 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'profiles_notifier.g.dart'; +@riverpod +class ProfilesSortNotifier extends _$ProfilesSortNotifier with AppLogger { + @override + ({ProfilesSort by, SortMode mode}) build() { + return (by: ProfilesSort.lastUpdate, mode: SortMode.descending); + } + + void changeSort(ProfilesSort sortBy) => + state = (by: sortBy, mode: state.mode); + + void toggleMode() => state = ( + by: state.by, + mode: state.mode == SortMode.ascending + ? SortMode.descending + : SortMode.ascending + ); +} + @riverpod class ProfilesNotifier extends _$ProfilesNotifier with AppLogger { @override Stream> build() { + final sort = ref.watch(profilesSortNotifierProvider); return _profilesRepo - .watchAll() + .watchAll(sort: sort.by, mode: sort.mode) .map((event) => event.getOrElse((l) => throw l)); } diff --git a/lib/features/profiles/view/profiles_modal.dart b/lib/features/profiles/view/profiles_modal.dart index 3658137a..c041a137 100644 --- a/lib/features/profiles/view/profiles_modal.dart +++ b/lib/features/profiles/view/profiles_modal.dart @@ -1,7 +1,15 @@ 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/enums.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/profiles/notifier/notifier.dart'; +import 'package:hiddify/utils/utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:recase/recase.dart'; class ProfilesModal extends HookConsumerWidget { const ProfilesModal({ @@ -13,25 +21,121 @@ class ProfilesModal extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final t = ref.watch(translationsProvider); final asyncProfiles = ref.watch(profilesNotifierProvider); - return Scaffold( - backgroundColor: Colors.transparent, - body: CustomScrollView( - controller: scrollController, - slivers: [ - switch (asyncProfiles) { - AsyncData(value: final profiles) => SliverList.builder( - itemBuilder: (context, index) { - final profile = profiles[index]; - return ProfileTile(profile: profile); - }, - itemCount: profiles.length, - ), - // TODO: handle loading and error - _ => const SliverToBoxAdapter(), - }, - ], + return Stack( + children: [ + CustomScrollView( + controller: scrollController, + slivers: [ + switch (asyncProfiles) { + AsyncData(value: final profiles) => SliverList.builder( + itemBuilder: (context, index) { + final profile = profiles[index]; + return ProfileTile(profile: profile); + }, + itemCount: profiles.length, + ), + AsyncError(:final error) => SliverErrorBodyPlaceholder( + t.presentError(error), + ), + AsyncLoading() => const SliverLoadingBodyPlaceholder(), + _ => const SliverToBoxAdapter(), + }, + const SliverGap(48), + ], + ), + Positioned.fill( + child: Align( + alignment: Alignment.bottomCenter, + child: ButtonBar( + alignment: MainAxisAlignment.center, + children: [ + FilledButton.icon( + onPressed: () { + const AddProfileRoute().push(context); + }, + icon: const Icon(Icons.add), + label: Text(t.profile.add.shortBtnTxt.titleCase), + ), + FilledButton.icon( + onPressed: () { + showDialog( + context: context, + builder: (context) { + return const ProfilesSortModal(); + }, + ); + }, + icon: const Icon(Icons.filter_list), + label: Text(t.general.sort.titleCase), + ), + ], + ), + ), + ), + ], + ); + } +} + +class ProfilesSortModal extends HookConsumerWidget { + const ProfilesSortModal({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final t = ref.watch(translationsProvider); + + return AlertDialog( + title: Text(t.general.sortBy.titleCase), + content: Consumer( + builder: (context, ref, child) { + final sort = ref.watch(profilesSortNotifierProvider); + return SingleChildScrollView( + child: Column( + children: [ + ...ProfilesSort.values.map( + (e) { + final selected = sort.by == e; + final double arrowTurn = + sort.mode == SortMode.ascending ? 0 : 0.5; + + return ListTile( + title: Text(e.present(t)), + onTap: () { + if (selected) { + ref + .read(profilesSortNotifierProvider.notifier) + .toggleMode(); + } else { + ref + .read(profilesSortNotifierProvider.notifier) + .changeSort(e); + } + }, + selected: selected, + trailing: selected + ? IconButton( + onPressed: () { + ref + .read(profilesSortNotifierProvider.notifier) + .toggleMode(); + }, + icon: AnimatedRotation( + turns: arrowTurn, + duration: const Duration(milliseconds: 100), + child: const Icon(Icons.arrow_upward), + ), + ) + : null, + ); + }, + ), + ], + ), + ); + }, ), ); }