Refactor profiles

This commit is contained in:
problematicconsumer
2023-11-26 21:20:58 +03:30
parent e2f5f51176
commit 829d58a1a2
49 changed files with 1206 additions and 1024 deletions

View File

@@ -0,0 +1,251 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hiddify/core/core_providers.dart';
import 'package:hiddify/core/router/router.dart';
import 'package:hiddify/features/common/qr_code_scanner_screen.dart';
import 'package:hiddify/features/profile/notifier/profile_notifier.dart';
import 'package:hiddify/utils/utils.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class AddProfileModal extends HookConsumerWidget {
const AddProfileModal({
super.key,
this.url,
this.scrollController,
});
final String? url;
final ScrollController? scrollController;
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(translationsProvider);
final addProfileState = ref.watch(addProfileProvider);
ref.listen(
addProfileProvider,
(previous, next) {
if (next case AsyncData(value: final _?)) {
WidgetsBinding.instance.addPostFrameCallback(
(_) {
if (context.mounted) context.pop();
},
);
}
},
);
useMemoized(() async {
await Future.delayed(const Duration(milliseconds: 200));
if (url != null && context.mounted) {
if (addProfileState.isLoading) return;
ref.read(addProfileProvider.notifier).add(url!);
}
});
final theme = Theme.of(context);
const buttonsPadding = 24.0;
const buttonsGap = 16.0;
return SingleChildScrollView(
controller: scrollController,
child: AnimatedSize(
duration: const Duration(milliseconds: 250),
child: LayoutBuilder(
builder: (context, constraints) {
// temporary solution, aspect ratio widget relies on height and in a row there no height!
final buttonWidth =
constraints.maxWidth / 2 - (buttonsPadding + (buttonsGap / 2));
return AnimatedCrossFade(
firstChild: SizedBox(
height: buttonWidth.clamp(0, 168),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 64),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
t.profile.add.addingProfileMsg,
style: theme.textTheme.bodySmall,
),
const Gap(8),
const LinearProgressIndicator(
backgroundColor: Colors.transparent,
),
],
),
),
),
secondChild: Column(
children: [
Padding(
padding:
const EdgeInsets.symmetric(horizontal: buttonsPadding),
child: Row(
children: [
_Button(
key: const ValueKey("add_from_clipboard_button"),
label: t.profile.add.fromClipboard,
icon: Icons.content_paste,
size: buttonWidth,
onTap: () async {
final captureResult =
await Clipboard.getData(Clipboard.kTextPlain)
.then((value) => value?.text ?? '');
if (addProfileState.isLoading) return;
ref
.read(addProfileProvider.notifier)
.add(captureResult);
},
),
const Gap(buttonsGap),
if (!PlatformUtils.isDesktop)
_Button(
key: const ValueKey("add_by_qr_code_button"),
label: t.profile.add.scanQr,
icon: Icons.qr_code_scanner,
size: buttonWidth,
onTap: () async {
final captureResult =
await const QRCodeScannerScreen()
.open(context);
if (captureResult == null) return;
if (addProfileState.isLoading) return;
ref
.read(addProfileProvider.notifier)
.add(captureResult);
},
)
else
_Button(
key: const ValueKey("add_manually_button"),
label: t.profile.add.manually,
icon: Icons.add,
size: buttonWidth,
onTap: () async {
context.pop();
await const NewProfileRoute().push(context);
},
),
],
),
),
if (!PlatformUtils.isDesktop)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: buttonsPadding,
vertical: 16,
),
child: Semantics(
button: true,
child: SizedBox(
height: 36,
child: Material(
key: const ValueKey("add_manually_button"),
elevation: 8,
color: theme.colorScheme.surface,
surfaceTintColor: theme.colorScheme.surfaceTint,
shadowColor: Colors.transparent,
borderRadius: BorderRadius.circular(8),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () async {
context.pop();
await const NewProfileRoute().push(context);
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.add,
color: theme.colorScheme.primary,
),
const Gap(8),
Text(
t.profile.add.manually,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
),
),
],
),
),
),
),
),
),
const Gap(24),
],
),
crossFadeState: addProfileState.isLoading
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 250),
);
},
),
),
);
}
}
class _Button extends StatelessWidget {
const _Button({
super.key,
required this.label,
required this.icon,
required this.size,
required this.onTap,
});
final String label;
final IconData icon;
final double size;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = theme.colorScheme.primary;
return Semantics(
button: true,
child: SizedBox(
width: size,
height: size,
child: Material(
elevation: 8,
color: theme.colorScheme.surface,
surfaceTintColor: theme.colorScheme.surfaceTint,
shadowColor: Colors.transparent,
borderRadius: BorderRadius.circular(8),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: size / 3,
color: color,
),
const Gap(16),
Flexible(
child: Text(
label,
style: theme.textTheme.labelLarge?.copyWith(color: color),
),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,85 @@
import 'package:drift/drift.dart';
import 'package:hiddify/data/local/database.dart';
import 'package:hiddify/features/profile/model/profile_entity.dart';
extension ProfileEntityMapper on ProfileEntity {
ProfileEntriesCompanion toEntry() {
return switch (this) {
RemoteProfileEntity(:final url, :final options, :final subInfo) =>
ProfileEntriesCompanion.insert(
id: id,
type: ProfileType.remote,
active: active,
name: name,
url: Value(url),
lastUpdate: lastUpdate,
updateInterval: Value(options?.updateInterval),
upload: Value(subInfo?.upload),
download: Value(subInfo?.download),
total: Value(subInfo?.total),
expire: Value(subInfo?.expire),
webPageUrl: Value(subInfo?.webPageUrl),
supportUrl: Value(subInfo?.supportUrl),
),
LocalProfileEntity() => ProfileEntriesCompanion.insert(
id: id,
type: ProfileType.local,
active: active,
name: name,
lastUpdate: lastUpdate,
),
};
}
}
extension RemoteProfileEntityMapper on RemoteProfileEntity {
ProfileEntriesCompanion subInfoPatch() {
return ProfileEntriesCompanion(
upload: Value(subInfo?.upload),
download: Value(subInfo?.download),
total: Value(subInfo?.total),
expire: Value(subInfo?.expire),
webPageUrl: Value(subInfo?.webPageUrl),
supportUrl: Value(subInfo?.supportUrl),
);
}
}
extension ProfileEntryMapper on ProfileEntry {
ProfileEntity toEntity() {
ProfileOptions? options;
if (updateInterval != null) {
options = ProfileOptions(updateInterval: updateInterval!);
}
SubscriptionInfo? subInfo;
if (upload != null && download != null && total != null && expire != null) {
subInfo = SubscriptionInfo(
upload: upload!,
download: download!,
total: total!,
expire: expire!,
webPageUrl: webPageUrl,
supportUrl: supportUrl,
);
}
return switch (type) {
ProfileType.remote => RemoteProfileEntity(
id: id,
active: active,
name: name,
url: url!,
lastUpdate: lastUpdate,
options: options,
subInfo: subInfo,
),
ProfileType.local => LocalProfileEntity(
id: id,
active: active,
name: name,
lastUpdate: lastUpdate,
),
};
}
}

View File

@@ -0,0 +1,32 @@
import 'package:hiddify/data/data_providers.dart';
import 'package:hiddify/features/profile/data/profile_data_source.dart';
import 'package:hiddify/features/profile/data/profile_path_resolver.dart';
import 'package:hiddify/features/profile/data/profile_repository.dart';
import 'package:hiddify/services/service_providers.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'profile_data_providers.g.dart';
@Riverpod(keepAlive: true)
Future<ProfileRepository> profileRepository(ProfileRepositoryRef ref) async {
final repo = ProfileRepositoryImpl(
profileDataSource: ref.watch(profileDataSourceProvider),
profilePathResolver: ref.watch(profilePathResolverProvider),
configValidator: ref.watch(coreFacadeProvider).parseConfig,
dio: ref.watch(dioProvider),
);
await repo.init().getOrElse((l) => throw l).run();
return repo;
}
@Riverpod(keepAlive: true)
ProfileDataSource profileDataSource(ProfileDataSourceRef ref) {
return ProfileDao(ref.watch(appDatabaseProvider));
}
@Riverpod(keepAlive: true)
ProfilePathResolver profilePathResolver(ProfilePathResolverRef ref) {
return ProfilePathResolver(
ref.watch(filesEditorServiceProvider).dirs.workingDir,
);
}

View File

@@ -0,0 +1,133 @@
import 'package:drift/drift.dart';
import 'package:hiddify/data/local/database.dart';
import 'package:hiddify/data/local/tables.dart';
import 'package:hiddify/features/profile/model/profile_sort_enum.dart';
import 'package:hiddify/utils/utils.dart';
part 'profile_data_source.g.dart';
abstract interface class ProfileDataSource {
Future<ProfileEntry?> getById(String id);
Future<ProfileEntry?> getByUrl(String url);
Stream<ProfileEntry?> watchActiveProfile();
Stream<int> watchProfilesCount();
Stream<List<ProfileEntry>> watchAll({
required ProfilesSort sort,
required SortMode sortMode,
});
Future<void> insert(ProfileEntriesCompanion entry);
Future<void> edit(String id, ProfileEntriesCompanion entry);
Future<void> deleteById(String id);
}
Map<SortMode, OrderingMode> orderMap = {
SortMode.ascending: OrderingMode.asc,
SortMode.descending: OrderingMode.desc,
};
@DriftAccessor(tables: [ProfileEntries])
class ProfileDao extends DatabaseAccessor<AppDatabase>
with _$ProfileDaoMixin, InfraLogger
implements ProfileDataSource {
ProfileDao(super.db);
@override
Future<ProfileEntry?> getById(String id) async {
return (profileEntries.select()..where((tbl) => tbl.id.equals(id)))
.getSingleOrNull();
}
@override
Future<ProfileEntry?> getByUrl(String url) async {
return (select(profileEntries)
..where((tbl) => tbl.url.like('%$url%'))
..limit(1))
.getSingleOrNull();
}
@override
Stream<ProfileEntry?> watchActiveProfile() {
return (profileEntries.select()
..where((tbl) => tbl.active.equals(true))
..limit(1))
.watchSingleOrNull();
}
@override
Stream<int> watchProfilesCount() {
final count = profileEntries.id.count();
return (profileEntries.selectOnly()..addColumns([count]))
.map((exp) => exp.read(count)!)
.watchSingle();
}
@override
Stream<List<ProfileEntry>> watchAll({
required ProfilesSort sort,
required SortMode sortMode,
}) {
return (profileEntries.select()
..orderBy(
[
(tbl) {
final trafficRatio = (tbl.download + tbl.upload) / tbl.total;
final isExpired =
tbl.expire.isSmallerOrEqualValue(DateTime.now());
return OrderingTerm(
expression: (trafficRatio.isNull() |
trafficRatio.isSmallerThanValue(1)) &
(isExpired.isNull() | isExpired.equals(false)),
mode: OrderingMode.desc,
);
},
switch (sort) {
ProfilesSort.name => (tbl) => OrderingTerm(
expression: tbl.name,
mode: orderMap[sortMode]!,
),
ProfilesSort.lastUpdate => (tbl) => OrderingTerm(
expression: tbl.lastUpdate,
mode: orderMap[sortMode]!,
),
},
],
))
.watch();
}
@override
Future<void> insert(ProfileEntriesCompanion entry) async {
await transaction(
() async {
if (entry.active.present && entry.active.value) {
await update(profileEntries)
.write(const ProfileEntriesCompanion(active: Value(false)));
}
await into(profileEntries).insert(entry);
},
);
}
@override
Future<void> edit(String id, ProfileEntriesCompanion entry) async {
await transaction(
() async {
if (entry.active.present && entry.active.value) {
await update(profileEntries)
.write(const ProfileEntriesCompanion(active: Value(false)));
}
await (update(profileEntries)..where((tbl) => tbl.id.equals(id)))
.write(entry);
},
);
}
@override
Future<void> deleteById(String id) async {
await transaction(
() async {
await (delete(profileEntries)..where((tbl) => tbl.id.equals(id))).go();
},
);
}
}

View File

@@ -0,0 +1,105 @@
import 'dart:convert';
import 'package:dartx/dartx.dart';
import 'package:hiddify/features/profile/model/profile_entity.dart';
import 'package:hiddify/utils/utils.dart';
import 'package:uuid/uuid.dart';
/// parse profile subscription url and headers for data
///
/// ***name parser hierarchy:***
/// - `profile-title` header
/// - `content-disposition` header
/// - url fragment (example: `https://example.com/config#user`) -> name=`user`
/// - url filename extension (example: `https://example.com/config.json`) -> name=`config`
/// - if none of these methods return a non-blank string, fallback to `Remote Profile`
abstract class ProfileParser {
static RemoteProfileEntity parse(
String url,
Map<String, List<String>> headers,
) {
var name = '';
if (headers['profile-title'] case [final titleHeader]) {
if (titleHeader.startsWith("base64:")) {
name =
utf8.decode(base64.decode(titleHeader.replaceFirst("base64:", "")));
} else {
name = titleHeader.trim();
}
}
if (headers['content-disposition'] case [final contentDispositionHeader]
when name.isEmpty) {
final regExp = RegExp('filename="([^"]*)"');
final match = regExp.firstMatch(contentDispositionHeader);
if (match != null && match.groupCount >= 1) {
name = match.group(1) ?? '';
}
}
if (Uri.parse(url).fragment case final fragment when name.isEmpty) {
name = fragment;
}
if (url.split("/").lastOrNull case final part? when name.isEmpty) {
final pattern = RegExp(r"\.(json|yaml|yml|txt)[\s\S]*");
name = part.replaceFirst(pattern, "");
}
if (name.isBlank) name = "Remote Profile";
ProfileOptions? options;
if (headers['profile-update-interval'] case [final updateIntervalStr]) {
final updateInterval = Duration(hours: int.parse(updateIntervalStr));
options = ProfileOptions(updateInterval: updateInterval);
}
SubscriptionInfo? subInfo;
if (headers['subscription-userinfo'] case [final subInfoStr]) {
subInfo = parseSubscriptionInfo(subInfoStr);
}
if (subInfo != null) {
if (headers['profile-web-page-url'] case [final profileWebPageUrl]
when isUrl(profileWebPageUrl)) {
subInfo = subInfo.copyWith(webPageUrl: profileWebPageUrl);
}
if (headers['support-url'] case [final profileSupportUrl]
when isUrl(profileSupportUrl)) {
subInfo = subInfo.copyWith(supportUrl: profileSupportUrl);
}
}
return RemoteProfileEntity(
id: const Uuid().v4(),
active: false,
name: name,
url: url,
lastUpdate: DateTime.now(),
options: options,
subInfo: subInfo,
);
}
static SubscriptionInfo? parseSubscriptionInfo(String subInfoStr) {
final values = subInfoStr.split(';');
final map = {
for (final v in values)
v.split('=').first.trim():
num.tryParse(v.split('=').second.trim())?.toInt(),
};
if (map
case {
"upload": final upload?,
"download": final download?,
"total": final total,
"expire": final expire
}) {
return SubscriptionInfo(
upload: upload,
download: download,
total: total ?? 9223372036854775807,
expire: DateTime.fromMillisecondsSinceEpoch(
(expire ?? 92233720368) * 1000,
),
);
}
return null;
}
}

View File

@@ -0,0 +1,17 @@
import 'dart:io';
import 'package:path/path.dart' as p;
class ProfilePathResolver {
const ProfilePathResolver(this._workingDir);
final Directory _workingDir;
Directory get directory => Directory(p.join(_workingDir.path, "configs"));
File file(String fileName) {
return File(p.join(directory.path, "$fileName.json"));
}
File tempFile(String fileName) => file("$fileName.tmp");
}

View File

@@ -0,0 +1,392 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:drift/drift.dart';
import 'package:fpdart/fpdart.dart';
import 'package:hiddify/data/local/database.dart';
import 'package:hiddify/data/repository/exception_handlers.dart';
import 'package:hiddify/domain/core_service_failure.dart';
import 'package:hiddify/features/profile/data/profile_data_mapper.dart';
import 'package:hiddify/features/profile/data/profile_data_source.dart';
import 'package:hiddify/features/profile/data/profile_parser.dart';
import 'package:hiddify/features/profile/data/profile_path_resolver.dart';
import 'package:hiddify/features/profile/model/profile_entity.dart';
import 'package:hiddify/features/profile/model/profile_failure.dart';
import 'package:hiddify/features/profile/model/profile_sort_enum.dart';
import 'package:hiddify/utils/custom_loggers.dart';
import 'package:hiddify/utils/link_parsers.dart';
import 'package:meta/meta.dart';
import 'package:retry/retry.dart';
import 'package:uuid/uuid.dart';
abstract interface class ProfileRepository {
TaskEither<ProfileFailure, Unit> init();
TaskEither<ProfileFailure, ProfileEntity?> getById(String id);
Stream<Either<ProfileFailure, ProfileEntity?>> watchActiveProfile();
Stream<Either<ProfileFailure, bool>> watchHasAnyProfile();
Stream<Either<ProfileFailure, List<ProfileEntity>>> watchAll({
ProfilesSort sort = ProfilesSort.lastUpdate,
SortMode sortMode = SortMode.ascending,
});
TaskEither<ProfileFailure, Unit> addByUrl(
String url, {
bool markAsActive = false,
});
TaskEither<ProfileFailure, Unit> addByContent(
String content, {
required String name,
bool markAsActive = false,
});
TaskEither<ProfileFailure, Unit> add(RemoteProfileEntity baseProfile);
TaskEither<ProfileFailure, Unit> updateSubscription(
RemoteProfileEntity baseProfile,
);
TaskEither<ProfileFailure, Unit> patch(ProfileEntity profile);
TaskEither<ProfileFailure, Unit> setAsActive(String id);
TaskEither<ProfileFailure, Unit> deleteById(String id);
}
class ProfileRepositoryImpl
with ExceptionHandler, InfraLogger
implements ProfileRepository {
ProfileRepositoryImpl({
required this.profileDataSource,
required this.profilePathResolver,
required this.configValidator,
required this.dio,
});
final ProfileDataSource profileDataSource;
final ProfilePathResolver profilePathResolver;
final TaskEither<CoreServiceFailure, Unit> Function(
String path,
String tempPath,
bool debug,
) configValidator;
final Dio dio;
@override
TaskEither<ProfileFailure, Unit> init() {
return exceptionHandler(
() async {
if (!await profilePathResolver.directory.exists()) {
await profilePathResolver.directory.create(recursive: true);
}
return right(unit);
},
ProfileUnexpectedFailure.new,
);
}
@override
TaskEither<ProfileFailure, ProfileEntity?> getById(String id) {
return TaskEither.tryCatch(
() => profileDataSource.getById(id).then((value) => value?.toEntity()),
ProfileUnexpectedFailure.new,
);
}
@override
Stream<Either<ProfileFailure, ProfileEntity?>> watchActiveProfile() {
return profileDataSource
.watchActiveProfile()
.map((event) => event?.toEntity())
.handleExceptions(
(error, stackTrace) {
loggy.error("error watching active profile", error, stackTrace);
return ProfileUnexpectedFailure(error, stackTrace);
},
);
}
@override
Stream<Either<ProfileFailure, bool>> watchHasAnyProfile() {
return profileDataSource
.watchProfilesCount()
.map((event) => event != 0)
.handleExceptions(ProfileUnexpectedFailure.new);
}
@override
Stream<Either<ProfileFailure, List<ProfileEntity>>> watchAll({
ProfilesSort sort = ProfilesSort.lastUpdate,
SortMode sortMode = SortMode.ascending,
}) {
return profileDataSource
.watchAll(sort: sort, sortMode: sortMode)
.map((event) => event.map((e) => e.toEntity()).toList())
.handleExceptions(ProfileUnexpectedFailure.new);
}
@override
TaskEither<ProfileFailure, Unit> addByUrl(
String url, {
bool markAsActive = false,
}) {
return exceptionHandler(
() async {
final existingProfile = await profileDataSource
.getByUrl(url)
.then((value) => value?.toEntity());
if (existingProfile case RemoteProfileEntity()) {
loggy.info("profile with same url already exists, updating");
final baseProfile = markAsActive
? existingProfile.copyWith(active: true)
: existingProfile;
return updateSubscription(baseProfile).run();
}
final profileId = const Uuid().v4();
return fetch(url, profileId)
.flatMap(
(profile) => TaskEither(
() async {
await profileDataSource.insert(
profile
.copyWith(id: profileId, active: markAsActive)
.toEntry(),
);
return right(unit);
},
),
)
.run();
},
(error, stackTrace) {
loggy.warning("error adding profile by url", error, stackTrace);
return ProfileUnexpectedFailure(error, stackTrace);
},
);
}
@override
TaskEither<ProfileFailure, Unit> addByContent(
String content, {
required String name,
bool markAsActive = false,
}) {
return exceptionHandler(
() async {
final profileId = const Uuid().v4();
final file = profilePathResolver.file(profileId);
final tempFile = profilePathResolver.tempFile(profileId);
try {
await tempFile.writeAsString(content);
final parseResult =
await configValidator(file.path, tempFile.path, false).run();
return parseResult.fold(
(err) async {
loggy.warning("error parsing config", err);
return left(ProfileFailure.invalidConfig(err.msg));
},
(_) async {
final profile = LocalProfileEntity(
id: profileId,
active: markAsActive,
name: name,
lastUpdate: DateTime.now(),
);
await profileDataSource.insert(profile.toEntry());
return right(unit);
},
);
} finally {
if (tempFile.existsSync()) tempFile.deleteSync();
}
},
(error, stackTrace) {
loggy.warning("error adding profile by content", error, stackTrace);
return ProfileUnexpectedFailure(error, stackTrace);
},
);
}
@override
TaskEither<ProfileFailure, Unit> add(RemoteProfileEntity baseProfile) {
return exceptionHandler(
() async {
return fetch(baseProfile.url, baseProfile.id)
.flatMap(
(remoteProfile) => TaskEither(() async {
await profileDataSource.insert(
baseProfile
.copyWith(
subInfo: remoteProfile.subInfo,
lastUpdate: DateTime.now(),
)
.toEntry(),
);
return right(unit);
}),
)
.run();
},
(error, stackTrace) {
loggy.warning("error adding profile", error, stackTrace);
return ProfileUnexpectedFailure(error, stackTrace);
},
);
}
@override
TaskEither<ProfileFailure, Unit> updateSubscription(
RemoteProfileEntity baseProfile,
) {
return exceptionHandler(
() async {
loggy.debug(
"updating profile [${baseProfile.name} (${baseProfile.id})]",
);
return fetch(baseProfile.url, baseProfile.id)
.flatMap(
(remoteProfile) => TaskEither(() async {
await profileDataSource.edit(
baseProfile.id,
remoteProfile
.subInfoPatch()
.copyWith(lastUpdate: Value(DateTime.now())),
);
return right(unit);
}),
)
.run();
},
(error, stackTrace) {
loggy.warning("error updating profile", error, stackTrace);
return ProfileUnexpectedFailure(error, stackTrace);
},
);
}
@override
TaskEither<ProfileFailure, Unit> patch(ProfileEntity profile) {
return exceptionHandler(
() async {
loggy.debug(
"editing profile [${profile.name} (${profile.id})]",
);
await profileDataSource.edit(profile.id, profile.toEntry());
return right(unit);
},
(error, stackTrace) {
loggy.warning("error editing profile", error, stackTrace);
return ProfileUnexpectedFailure(error, stackTrace);
},
);
}
@override
TaskEither<ProfileFailure, Unit> setAsActive(String id) {
return TaskEither.tryCatch(
() async {
await profileDataSource.edit(
id,
const ProfileEntriesCompanion(active: Value(true)),
);
return unit;
},
ProfileUnexpectedFailure.new,
);
}
@override
TaskEither<ProfileFailure, Unit> deleteById(String id) {
return TaskEither.tryCatch(
() async {
await profileDataSource.deleteById(id);
await profilePathResolver.file(id).delete();
return unit;
},
ProfileUnexpectedFailure.new,
);
}
final _subInfoHeaders = [
'profile-title',
'content-disposition',
'subscription-userinfo',
'profile-update-interval',
'support-url',
'profile-web-page-url',
];
@visibleForTesting
TaskEither<ProfileFailure, RemoteProfileEntity> fetch(
String url,
String fileName,
) {
return TaskEither(
() async {
final file = profilePathResolver.file(fileName);
final tempFile = profilePathResolver.tempFile(fileName);
try {
final response = await retry(
() async => dio.download(url.trim(), tempFile.path),
maxAttempts: 3,
);
final headers =
await _populateHeaders(response.headers.map, tempFile.path);
final parseResult =
await configValidator(file.path, tempFile.path, false).run();
return parseResult.fold(
(err) async {
loggy.warning("error parsing config", err);
return left(ProfileFailure.invalidConfig(err.msg));
},
(_) async {
final profile = ProfileParser.parse(url, headers);
return right(profile);
},
);
} finally {
if (tempFile.existsSync()) tempFile.deleteSync();
}
},
);
}
Future<Map<String, List<String>>> _populateHeaders(
Map<String, List<String>> headers,
String path,
) async {
var headersFound = 0;
for (final key in _subInfoHeaders) {
if (headers.containsKey(key)) headersFound++;
}
if (headersFound >= 4) return headers;
loggy.debug(
"only [$headersFound] headers found, checking file content for possible information",
);
var content = await File(path).readAsString();
content = safeDecodeBase64(content);
final lines = content.split("\n");
final linesToProcess = lines.length < 10 ? lines.length : 10;
for (int i = 0; i < linesToProcess; i++) {
final line = lines[i];
if (line.startsWith("#") || line.startsWith("//")) {
final index = line.indexOf(':');
if (index == -1) continue;
final key = line
.substring(0, index)
.replaceFirst(RegExp("^#|//"), "")
.trim()
.toLowerCase();
final value = line.substring(index + 1).trim();
if (!headers.keys.contains(key) &&
_subInfoHeaders.contains(key) &&
value.isNotEmpty) {
headers[key] = [value];
}
}
}
return headers;
}
}

View File

@@ -0,0 +1,178 @@
import 'package:dartx/dartx.dart';
import 'package:fpdart/fpdart.dart';
import 'package:hiddify/features/profile/data/profile_data_providers.dart';
import 'package:hiddify/features/profile/data/profile_repository.dart';
import 'package:hiddify/features/profile/details/profile_details_state.dart';
import 'package:hiddify/features/profile/model/profile_entity.dart';
import 'package:hiddify/features/profile/model/profile_failure.dart';
import 'package:hiddify/utils/utils.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:uuid/uuid.dart';
part 'profile_details_notifier.g.dart';
@riverpod
class ProfileDetailsNotifier extends _$ProfileDetailsNotifier with AppLogger {
@override
Future<ProfileDetailsState> build(
String id, {
String? url,
String? profileName,
}) async {
if (id == 'new') {
return ProfileDetailsState(
profile: RemoteProfileEntity(
id: const Uuid().v4(),
active: true,
name: profileName ?? "",
url: url ?? "",
lastUpdate: DateTime.now(),
),
);
}
final failureOrProfile = await _profilesRepo.getById(id).run();
return failureOrProfile.match(
(err) {
loggy.warning('failed to load profile', err);
throw err;
},
(profile) {
if (profile == null) {
loggy.warning('profile with id: [$id] does not exist');
throw const ProfileNotFoundFailure();
}
_originalProfile = profile;
return ProfileDetailsState(profile: profile, isEditing: true);
},
);
}
ProfileRepository get _profilesRepo =>
ref.read(profileRepositoryProvider).requireValue;
ProfileEntity? _originalProfile;
void setField({String? name, String? url, Option<int>? updateInterval}) {
if (state case AsyncData(:final value)) {
state = AsyncData(
value.copyWith(
profile: value.profile.map(
remote: (rp) => rp.copyWith(
name: name ?? rp.name,
url: url ?? rp.url,
options: updateInterval == null
? rp.options
: updateInterval.fold(
() => null,
(t) => ProfileOptions(
updateInterval: Duration(hours: t),
),
),
),
local: (lp) => lp.copyWith(name: name ?? lp.name),
),
),
);
}
}
Future<void> save() async {
if (state case AsyncData(:final value)) {
if (value.save case AsyncLoading()) return;
final profile = value.profile;
Either<ProfileFailure, Unit>? failureOrSuccess;
state = AsyncData(value.copyWith(save: const AsyncLoading()));
switch (profile) {
case RemoteProfileEntity():
loggy.debug(
'saving profile, url: [${profile.url}], name: [${profile.name}]',
);
if (profile.name.isBlank || profile.url.isBlank) {
loggy.debug('save: invalid arguments');
} else if (value.isEditing) {
if (_originalProfile case RemoteProfileEntity(:final url)
when url == profile.url) {
loggy.debug('editing profile');
failureOrSuccess = await _profilesRepo.patch(profile).run();
} else {
loggy.debug('updating profile');
failureOrSuccess =
await _profilesRepo.updateSubscription(profile).run();
}
} else {
loggy.debug('adding profile, url: [${profile.url}]');
failureOrSuccess = await _profilesRepo.add(profile).run();
}
case LocalProfileEntity() when value.isEditing:
loggy.debug('editing profile');
failureOrSuccess = await _profilesRepo.patch(profile).run();
default:
loggy.warning("local profile can't be added manually");
}
state = AsyncData(
value.copyWith(
save: failureOrSuccess?.fold(
(l) => AsyncError(l, StackTrace.current),
(_) => const AsyncData(null),
) ??
value.save,
showErrorMessages: true,
),
);
}
}
Future<void> updateProfile() async {
if (state case AsyncData(:final value)) {
if (value.update?.isLoading ?? false || !value.isEditing) return;
if (value.profile case LocalProfileEntity()) {
loggy.warning("local profile can't be updated");
return;
}
final profile = value.profile;
state = AsyncData(value.copyWith(update: const AsyncLoading()));
final failureOrUpdatedProfile = await _profilesRepo
.updateSubscription(profile as RemoteProfileEntity)
.flatMap((_) => _profilesRepo.getById(id))
.run();
state = AsyncData(
value.copyWith(
update: failureOrUpdatedProfile.match(
(l) => AsyncError(l, StackTrace.current),
(_) => const AsyncData(null),
),
profile: failureOrUpdatedProfile.match(
(_) => profile,
(updatedProfile) => updatedProfile ?? profile,
),
),
);
}
}
Future<void> delete() async {
if (state case AsyncData(:final value)) {
if (value.delete case AsyncLoading()) return;
final profile = value.profile;
state = AsyncData(value.copyWith(delete: const AsyncLoading()));
state = AsyncData(
value.copyWith(
delete: await AsyncValue.guard(() async {
await _profilesRepo
.deleteById(profile.id)
.getOrElse((l) => throw l)
.run();
}),
),
);
}
}
}

View File

@@ -0,0 +1,278 @@
import 'package:flutter/material.dart';
import 'package:fpdart/fpdart.dart';
import 'package:go_router/go_router.dart';
import 'package:hiddify/core/core_providers.dart';
import 'package:hiddify/domain/failures.dart';
import 'package:hiddify/features/common/confirmation_dialogs.dart';
import 'package:hiddify/features/profile/details/profile_details_notifier.dart';
import 'package:hiddify/features/profile/model/profile_entity.dart';
import 'package:hiddify/features/settings/widgets/widgets.dart';
import 'package:hiddify/utils/utils.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:humanizer/humanizer.dart';
class ProfileDetailsPage extends HookConsumerWidget with PresLogger {
const ProfileDetailsPage(this.id, {super.key});
final String id;
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(translationsProvider);
final provider = profileDetailsNotifierProvider(id);
final notifier = ref.watch(provider.notifier);
ref.listen(
provider.selectAsync((data) => data.save),
(_, next) async {
switch (await next) {
case AsyncData():
CustomToast.success(t.profile.save.successMsg).show(context);
WidgetsBinding.instance.addPostFrameCallback(
(_) {
if (context.mounted) context.pop();
},
);
case AsyncError(:final error):
final String action;
if (ref.read(provider) case AsyncData(value: final data)
when data.isEditing) {
action = t.profile.save.failureMsg;
} else {
action = t.profile.add.failureMsg;
}
CustomAlertDialog.fromErr(t.presentError(error, action: action))
.show(context);
}
},
);
ref.listen(
provider.selectAsync((data) => data.update),
(_, next) async {
switch (await next) {
case AsyncData():
CustomToast.success(t.profile.update.successMsg).show(context);
case AsyncError(:final error):
CustomAlertDialog.fromErr(t.presentError(error)).show(context);
}
},
);
ref.listen(
provider.selectAsync((data) => data.delete),
(_, next) async {
switch (await next) {
case AsyncData():
CustomToast.success(t.profile.delete.successMsg).show(context);
WidgetsBinding.instance.addPostFrameCallback(
(_) {
if (context.mounted) context.pop();
},
);
case AsyncError(:final error):
CustomToast.error(t.presentShortError(error)).show(context);
}
},
);
switch (ref.watch(provider)) {
case AsyncData(value: final state):
final showLoadingOverlay = state.isBusy ||
state.save is MutationSuccess ||
state.delete is MutationSuccess;
return Stack(
children: [
Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
title: Text(t.profile.detailsPageTitle),
pinned: true,
actions: [
if (state.isEditing)
PopupMenuButton(
itemBuilder: (context) {
return [
if (state.profile case RemoteProfileEntity())
PopupMenuItem(
child: Text(t.profile.update.buttonTxt),
onTap: () async {
await notifier.updateProfile();
},
),
PopupMenuItem(
child: Text(t.profile.delete.buttonTxt),
onTap: () async {
final deleteConfirmed =
await showConfirmationDialog(
context,
title: t.profile.delete.buttonTxt,
message: t.profile.delete.confirmationMsg,
);
if (deleteConfirmed) {
await notifier.delete();
}
},
),
];
},
),
],
),
Form(
autovalidateMode: state.showErrorMessages
? AutovalidateMode.always
: AutovalidateMode.disabled,
child: SliverList.list(
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: CustomTextFormField(
initialValue: state.profile.name,
onChanged: (value) =>
notifier.setField(name: value),
validator: (value) => (value?.isEmpty ?? true)
? t.profile.detailsForm.emptyNameMsg
: null,
label: t.profile.detailsForm.nameLabel,
hint: t.profile.detailsForm.nameHint,
),
),
if (state.profile
case RemoteProfileEntity(
:final url,
:final options
)) ...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: CustomTextFormField(
initialValue: url,
onChanged: (value) =>
notifier.setField(url: value),
validator: (value) =>
(value != null && !isUrl(value))
? t.profile.detailsForm.invalidUrlMsg
: null,
label: t.profile.detailsForm.urlLabel,
hint: t.profile.detailsForm.urlHint,
),
),
ListTile(
title: Text(t.profile.detailsForm.updateInterval),
subtitle: Text(
options?.updateInterval.toApproximateTime(
isRelativeToNow: false,
) ??
t.general.toggle.disabled,
),
leading: const Icon(Icons.update),
onTap: () async {
final intervalInHours = await SettingsInputDialog(
title: t.profile.detailsForm
.updateIntervalDialogTitle,
initialValue: options?.updateInterval.inHours,
optionalAction: (
t.general.state.disable,
() =>
notifier.setField(updateInterval: none()),
),
validator: isPort,
mapTo: int.tryParse,
digitsOnly: true,
).show(context);
if (intervalInHours == null) return;
notifier.setField(
updateInterval: optionOf(intervalInHours),
);
},
),
],
if (state.isEditing)
ListTile(
title: Text(t.profile.detailsForm.lastUpdate),
subtitle: Text(state.profile.lastUpdate.format()),
dense: true,
),
],
),
),
SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
OverflowBar(
spacing: 12,
overflowAlignment: OverflowBarAlignment.end,
children: [
OutlinedButton(
onPressed: context.pop,
child: Text(
MaterialLocalizations.of(context)
.cancelButtonLabel,
),
),
FilledButton(
onPressed: notifier.save,
child: Text(t.profile.save.buttonText),
),
],
),
],
),
),
),
],
),
),
if (showLoadingOverlay)
Positioned.fill(
child: Container(
color: Colors.black54,
padding: const EdgeInsets.symmetric(horizontal: 36),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
LinearProgressIndicator(
backgroundColor: Colors.transparent,
),
],
),
),
),
],
);
case AsyncError(:final error):
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
title: Text(t.profile.detailsPageTitle),
pinned: true,
),
SliverErrorBodyPlaceholder(t.presentShortError(error)),
],
),
);
default:
return const Scaffold();
}
}
}

View File

@@ -0,0 +1,22 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hiddify/features/profile/model/profile_entity.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
part 'profile_details_state.freezed.dart';
@freezed
class ProfileDetailsState with _$ProfileDetailsState {
const ProfileDetailsState._();
const factory ProfileDetailsState({
required ProfileEntity profile,
@Default(false) bool isEditing,
@Default(false) bool showErrorMessages,
AsyncValue<void>? save,
AsyncValue<void>? update,
AsyncValue<void>? delete,
}) = _ProfileDetailsState;
bool get isBusy =>
save is AsyncLoading || delete is AsyncLoading || update is AsyncLoading;
}

View File

@@ -0,0 +1,57 @@
import 'package:dartx/dartx.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'profile_entity.freezed.dart';
enum ProfileType { remote, local }
@freezed
sealed class ProfileEntity with _$ProfileEntity {
const ProfileEntity._();
const factory ProfileEntity.remote({
required String id,
required bool active,
required String name,
required String url,
required DateTime lastUpdate,
ProfileOptions? options,
SubscriptionInfo? subInfo,
}) = RemoteProfileEntity;
const factory ProfileEntity.local({
required String id,
required bool active,
required String name,
required DateTime lastUpdate,
}) = LocalProfileEntity;
}
@freezed
class ProfileOptions with _$ProfileOptions {
const factory ProfileOptions({
required Duration updateInterval,
}) = _ProfileOptions;
}
@freezed
class SubscriptionInfo with _$SubscriptionInfo {
const SubscriptionInfo._();
const factory SubscriptionInfo({
required int upload,
required int download,
required int total,
required DateTime expire,
String? webPageUrl,
String? supportUrl,
}) = _SubscriptionInfo;
bool get isExpired => expire <= DateTime.now();
int get consumption => upload + download;
double get ratio => (consumption / total).clamp(0, 1);
Duration get remaining => expire.difference(DateTime.now());
}

View File

@@ -0,0 +1,47 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hiddify/core/prefs/prefs.dart';
import 'package:hiddify/domain/failures.dart';
part 'profile_failure.freezed.dart';
@freezed
sealed class ProfileFailure with _$ProfileFailure, Failure {
const ProfileFailure._();
@With<UnexpectedFailure>()
const factory ProfileFailure.unexpected([
Object? error,
StackTrace? stackTrace,
]) = ProfileUnexpectedFailure;
const factory ProfileFailure.notFound() = ProfileNotFoundFailure;
@With<ExpectedFailure>()
const factory ProfileFailure.invalidUrl() = ProfileInvalidUrlFailure;
@With<ExpectedFailure>()
const factory ProfileFailure.invalidConfig([String? message]) =
ProfileInvalidConfigFailure;
@override
({String type, String? message}) present(TranslationsEn t) {
return switch (this) {
ProfileUnexpectedFailure() => (
type: t.failure.profiles.unexpected,
message: null,
),
ProfileNotFoundFailure() => (
type: t.failure.profiles.notFound,
message: null
),
ProfileInvalidUrlFailure() => (
type: t.failure.profiles.invalidUrl,
message: null,
),
ProfileInvalidConfigFailure(:final message) => (
type: t.failure.profiles.invalidConfig,
message: message
),
};
}
}

View File

@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'package:hiddify/core/prefs/prefs.dart';
enum ProfilesSort {
lastUpdate,
name;
String present(TranslationsEn t) {
return switch (this) {
lastUpdate => t.profile.sortBy.lastUpdate,
name => t.profile.sortBy.name,
};
}
IconData get icon => switch (this) {
lastUpdate => Icons.update,
name => Icons.sort_by_alpha,
};
}
enum SortMode { ascending, descending }

View File

@@ -0,0 +1,31 @@
import 'package:hiddify/features/profile/data/profile_data_providers.dart';
import 'package:hiddify/features/profile/model/profile_entity.dart';
import 'package:hiddify/utils/utils.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'active_profile_notifier.g.dart';
@Riverpod(keepAlive: true)
class ActiveProfile extends _$ActiveProfile with AppLogger {
@override
Stream<ProfileEntity?> build() {
loggy.debug("watching active profile");
return ref
.watch(profileRepositoryProvider)
.requireValue
.watchActiveProfile()
.map((event) => event.getOrElse((l) => throw l));
}
}
// TODO: move to specific feature
@Riverpod(keepAlive: true)
Stream<bool> hasAnyProfile(
HasAnyProfileRef ref,
) {
return ref
.watch(profileRepositoryProvider)
.requireValue
.watchHasAnyProfile()
.map((event) => event.getOrElse((l) => throw l));
}

View File

@@ -0,0 +1,140 @@
import 'package:fpdart/fpdart.dart';
import 'package:hiddify/core/core_providers.dart';
import 'package:hiddify/core/notification/in_app_notification_controller.dart';
import 'package:hiddify/core/prefs/general_prefs.dart';
import 'package:hiddify/domain/failures.dart';
import 'package:hiddify/features/common/connectivity/connectivity_controller.dart';
import 'package:hiddify/features/profile/data/profile_data_providers.dart';
import 'package:hiddify/features/profile/data/profile_repository.dart';
import 'package:hiddify/features/profile/model/profile_entity.dart';
import 'package:hiddify/features/profile/model/profile_failure.dart';
import 'package:hiddify/features/profile/notifier/active_profile_notifier.dart';
import 'package:hiddify/utils/riverpod_utils.dart';
import 'package:hiddify/utils/utils.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'profile_notifier.g.dart';
@riverpod
class AddProfile extends _$AddProfile with AppLogger {
@override
AsyncValue<Unit?> build() {
ref.disposeDelay(const Duration(minutes: 1));
ref.listenSelf(
(previous, next) {
final t = ref.read(translationsProvider);
final notification = ref.read(inAppNotificationControllerProvider);
switch (next) {
case AsyncData(value: final _?):
notification.showSuccessToast(t.profile.save.successMsg);
case AsyncError(:final error):
if (error case ProfileInvalidUrlFailure()) {
notification.showErrorToast(t.failure.profiles.invalidUrl);
} else {
notification.showErrorDialog(
t.presentError(error, action: t.profile.add.failureMsg),
);
}
}
},
);
return const AsyncData(null);
}
ProfileRepository get _profilesRepo =>
ref.read(profileRepositoryProvider).requireValue;
Future<void> add(String rawInput) async {
if (state.isLoading) return;
state = const AsyncLoading();
state = await AsyncValue.guard(
() async {
final activeProfile = await ref.read(activeProfileProvider.future);
final markAsActive =
activeProfile == null || ref.read(markNewProfileActiveProvider);
final TaskEither<ProfileFailure, Unit> task;
if (LinkParser.parse(rawInput) case (final link)?) {
loggy.debug("adding profile, url: [${link.url}]");
task = _profilesRepo.addByUrl(link.url, markAsActive: markAsActive);
} else if (LinkParser.protocol(rawInput) case (final parsed)?) {
loggy.debug("adding profile, content");
task = _profilesRepo.addByContent(
parsed.content,
name: parsed.name,
markAsActive: markAsActive,
);
} else {
loggy.debug("invalid content");
throw const ProfileInvalidUrlFailure();
}
return task.match(
(err) {
loggy.warning("failed to add profile", err);
throw err;
},
(_) {
loggy.info(
"successfully added profile, mark as active? [$markAsActive]",
);
return unit;
},
).run();
},
);
}
}
@riverpod
class UpdateProfile extends _$UpdateProfile with AppLogger {
@override
AsyncValue<Unit?> build(String id) {
ref.disposeDelay(const Duration(minutes: 1));
ref.listenSelf(
(previous, next) {
final t = ref.read(translationsProvider);
final notification = ref.read(inAppNotificationControllerProvider);
switch (next) {
case AsyncData(value: final _?):
notification.showSuccessToast(t.profile.update.successMsg);
case AsyncError(:final error):
notification.showErrorDialog(
t.presentError(error, action: t.profile.update.failureMsg),
);
}
},
);
return const AsyncData(null);
}
ProfileRepository get _profilesRepo =>
ref.read(profileRepositoryProvider).requireValue;
Future<void> updateProfile(RemoteProfileEntity profile) async {
if (state.isLoading) return;
state = const AsyncLoading();
state = await AsyncValue.guard(
() async {
return await _profilesRepo.updateSubscription(profile).match(
(err) {
loggy.warning("failed to update profile", err);
throw err;
},
(_) async {
loggy.info(
'successfully updated profile, was active? [${profile.active}]',
);
await ref.read(activeProfileProvider.future).then((active) async {
if (active != null && active.id == profile.id) {
await ref
.read(connectivityControllerProvider.notifier)
.reconnect(profile.id);
}
});
return unit;
},
).run();
},
);
}
}

View File

@@ -0,0 +1,92 @@
import 'package:dartx/dartx.dart';
import 'package:hiddify/data/data_providers.dart';
import 'package:hiddify/features/profile/data/profile_data_providers.dart';
import 'package:hiddify/features/profile/model/profile_entity.dart';
import 'package:hiddify/utils/custom_loggers.dart';
import 'package:meta/meta.dart';
import 'package:neat_periodic_task/neat_periodic_task.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'profiles_update_notifier.g.dart';
@Riverpod(keepAlive: true)
class ForegroundProfilesUpdateNotifier
extends _$ForegroundProfilesUpdateNotifier with AppLogger {
static const prefKey = "profiles_update_check";
static const interval = Duration(minutes: 15);
@override
Future<void> build() async {
loggy.debug("initializing");
var cycleCount = 0;
final scheduler = NeatPeriodicTaskScheduler(
name: 'profiles update worker',
interval: interval,
timeout: const Duration(minutes: 5),
task: () async {
loggy.debug("cycle [${cycleCount++}]");
await updateProfiles();
},
);
ref.onDispose(() async {
await scheduler.stop();
});
return scheduler.start();
}
@visibleForTesting
Future<void> updateProfiles() async {
try {
final previousRun = DateTime.tryParse(
ref.read(sharedPreferencesProvider).getString(prefKey) ?? "",
);
if (previousRun != null && previousRun.add(interval) > DateTime.now()) {
loggy.debug("too soon! previous run: [$previousRun]");
return;
}
loggy.debug("running, previous run: [$previousRun]");
final remoteProfiles = await ref
.read(profileRepositoryProvider)
.requireValue
.watchAll()
.map(
(event) => event.getOrElse((f) {
loggy.error("error getting profiles");
throw f;
}).whereType<RemoteProfileEntity>(),
)
.first;
await for (final profile in Stream.fromIterable(remoteProfiles)) {
final updateInterval = profile.options?.updateInterval;
if (updateInterval != null &&
updateInterval <= DateTime.now().difference(profile.lastUpdate)) {
await ref
.read(profileRepositoryProvider)
.requireValue
.updateSubscription(profile)
.mapLeft(
(l) => loggy.debug("error updating profile [${profile.id}]", l),
)
.map(
(_) =>
loggy.debug("profile [${profile.id}] updated successfully"),
)
.run();
} else {
loggy.debug(
"skipping profile [${profile.id}] update. last successful update: [${profile.lastUpdate}] - interval: [${profile.options?.updateInterval}]",
);
}
}
} finally {
await ref
.read(sharedPreferencesProvider)
.setString(prefKey, DateTime.now().toIso8601String());
}
}
}

View File

@@ -0,0 +1,83 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:fpdart/fpdart.dart';
import 'package:hiddify/data/data_providers.dart';
import 'package:hiddify/features/profile/data/profile_data_providers.dart';
import 'package:hiddify/features/profile/data/profile_repository.dart';
import 'package:hiddify/features/profile/model/profile_entity.dart';
import 'package:hiddify/features/profile/model/profile_sort_enum.dart';
import 'package:hiddify/utils/utils.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'profiles_overview_notifier.g.dart';
@riverpod
class ProfilesOverviewSortNotifier extends _$ProfilesOverviewSortNotifier
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 ProfilesOverviewNotifier extends _$ProfilesOverviewNotifier
with AppLogger {
@override
Stream<List<ProfileEntity>> build() {
final sort = ref.watch(profilesOverviewSortNotifierProvider);
return _profilesRepo
.watchAll(sort: sort.by, sortMode: sort.mode)
.map((event) => event.getOrElse((l) => throw l));
}
ProfileRepository get _profilesRepo =>
ref.read(profileRepositoryProvider).requireValue;
Future<Unit> selectActiveProfile(String id) async {
loggy.debug('changing active profile to: [$id]');
return _profilesRepo.setAsActive(id).getOrElse((err) {
loggy.warning('failed to set [$id] as active profile', err);
throw err;
}).run();
}
Future<void> deleteProfile(ProfileEntity profile) async {
loggy.debug('deleting profile: ${profile.name}');
await _profilesRepo.deleteById(profile.id).match(
(err) {
loggy.warning('failed to delete profile', err);
throw err;
},
(_) {
loggy.info(
'successfully deleted profile, was active? [${profile.active}]',
);
return unit;
},
).run();
}
Future<void> exportConfigToClipboard(ProfileEntity profile) async {
await ref.read(coreFacadeProvider).generateConfig(profile.id).match(
(err) {
loggy.warning('error generating config', err);
throw err;
},
(configJson) async {
await Clipboard.setData(ClipboardData(text: configJson));
},
).run();
}
}

View File

@@ -0,0 +1,140 @@
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/features/profile/model/profile_sort_enum.dart';
import 'package:hiddify/features/profile/overview/profiles_overview_notifier.dart';
import 'package:hiddify/features/profile/widget/profile_tile.dart';
import 'package:hiddify/utils/placeholders.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class ProfilesOverviewModal extends HookConsumerWidget {
const ProfilesOverviewModal({
super.key,
this.scrollController,
});
final ScrollController? scrollController;
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(translationsProvider);
final asyncProfiles = ref.watch(profilesOverviewNotifierProvider);
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.presentShortError(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),
),
FilledButton.icon(
onPressed: () {
showDialog(
context: context,
builder: (context) {
return const ProfilesSortModal();
},
);
},
icon: const Icon(Icons.sort),
label: Text(t.general.sort),
),
],
),
),
),
],
);
}
}
class ProfilesSortModal extends HookConsumerWidget {
const ProfilesSortModal({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(translationsProvider);
final sortNotifier =
ref.watch(profilesOverviewSortNotifierProvider.notifier);
return AlertDialog(
title: Text(t.general.sortBy),
content: Consumer(
builder: (context, ref, child) {
final sort = ref.watch(profilesOverviewSortNotifierProvider);
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) {
sortNotifier.toggleMode();
} else {
sortNotifier.changeSort(e);
}
},
selected: selected,
leading: Icon(e.icon),
trailing: selected
? IconButton(
onPressed: () {
sortNotifier.toggleMode();
},
icon: AnimatedRotation(
turns: arrowTurn,
duration: const Duration(milliseconds: 100),
child: Icon(
Icons.arrow_upward,
semanticLabel: sort.mode.name,
),
),
)
: null,
);
},
),
],
),
);
},
),
);
}
}

View File

@@ -0,0 +1,439 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hiddify/core/core_providers.dart';
import 'package:hiddify/core/prefs/prefs.dart';
import 'package:hiddify/core/router/router.dart';
import 'package:hiddify/domain/failures.dart';
import 'package:hiddify/features/common/confirmation_dialogs.dart';
import 'package:hiddify/features/common/qr_code_dialog.dart';
import 'package:hiddify/features/profile/model/profile_entity.dart';
import 'package:hiddify/features/profile/notifier/profile_notifier.dart';
import 'package:hiddify/features/profile/overview/profiles_overview_notifier.dart';
import 'package:hiddify/utils/utils.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:percent_indicator/percent_indicator.dart';
class ProfileTile extends HookConsumerWidget {
const ProfileTile({
super.key,
required this.profile,
this.isMain = false,
});
final ProfileEntity 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.presentShortError(err)).show(context);
},
initialOnSuccess: () {
if (context.mounted) context.pop();
},
);
final subInfo = switch (profile) {
RemoteProfileEntity(:final subInfo) => subInfo,
_ => null,
};
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: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (profile is RemoteProfileEntity || !isMain) ...[
SizedBox(
width: 48,
child: Semantics(
sortKey: const OrdinalSortKey(1),
child: ProfileActionButton(profile, !isMain),
),
),
VerticalDivider(
width: 1,
color: effectiveOutlineColor,
),
],
Expanded(
child: Semantics(
button: true,
sortKey: isMain ? const OrdinalSortKey(0) : null,
focused: isMain,
liveRegion: isMain,
namesRoute: isMain,
label: isMain ? t.profile.activeProfileBtnSemanticLabel : null,
child: InkWell(
onTap: () {
if (isMain) {
const ProfilesRoute().go(context);
} else {
if (selectActiveMutation.state.isInProgress) return;
if (profile.active) return;
selectActiveMutation.setFuture(
ref
.read(profilesOverviewNotifierProvider.notifier)
.selectActiveProfile(profile.id),
);
}
},
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: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
profile.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium,
semanticsLabel: t.profile
.activeProfileNameSemanticLabel(
name: profile.name,
),
),
),
const Icon(Icons.arrow_drop_down),
],
),
),
)
else
Text(
profile.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium,
semanticsLabel: profile.active
? t.profile.activeProfileNameSemanticLabel(
name: profile.name,
)
: t.profile.nonActiveProfileBtnSemanticLabel(
name: profile.name,
),
),
if (subInfo != null) ...[
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 ProfileEntity profile;
final bool showAllActions;
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(translationsProvider);
if (profile case RemoteProfileEntity() when !showAllActions) {
return Semantics(
button: true,
enabled: !ref.watch(updateProfileProvider(profile.id)).isLoading,
child: Tooltip(
message: t.profile.update.tooltip,
child: InkWell(
onTap: () {
if (ref.read(updateProfileProvider(profile.id)).isLoading) {
return;
}
ref
.read(updateProfileProvider(profile.id).notifier)
.updateProfile(profile as RemoteProfileEntity);
},
child: const Icon(Icons.update),
),
),
);
}
return ProfileActionsMenu(
profile,
(context, controller, child) {
return Semantics(
button: true,
child: Tooltip(
message: MaterialLocalizations.of(context).showMenuTooltip,
child: 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 ProfileEntity profile;
final MenuAnchorChildBuilder builder;
final Widget? child;
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(translationsProvider);
final exportConfigMutation = useMutation(
initialOnFailure: (err) {
CustomToast.error(t.presentShortError(err)).show(context);
},
initialOnSuccess: () =>
CustomToast.success(t.profile.share.exportConfigToClipboardSuccess)
.show(context),
);
final deleteProfileMutation = useMutation(
initialOnFailure: (err) {
CustomAlertDialog.fromErr(t.presentError(err)).show(context);
},
);
return MenuAnchor(
builder: builder,
menuChildren: [
if (profile case RemoteProfileEntity())
MenuItemButton(
leadingIcon: const Icon(Icons.update),
child: Text(t.profile.update.buttonTxt),
onPressed: () {
if (ref.read(updateProfileProvider(profile.id)).isLoading) {
return;
}
ref
.read(updateProfileProvider(profile.id).notifier)
.updateProfile(profile as RemoteProfileEntity);
},
),
SubmenuButton(
menuChildren: [
if (profile case RemoteProfileEntity(:final url, :final name)) ...[
MenuItemButton(
child: Text(t.profile.share.exportSubLinkToClipboard),
onPressed: () async {
final link = LinkParser.generateSubShareLink(url, name);
if (link.isNotEmpty) {
await Clipboard.setData(ClipboardData(text: link));
if (context.mounted) {
CustomToast(t.profile.share.exportToClipboardSuccess)
.show(context);
}
}
},
),
MenuItemButton(
child: Text(t.profile.share.subLinkQrCode),
onPressed: () async {
final link = LinkParser.generateSubShareLink(url, name);
if (link.isNotEmpty) {
await QrCodeDialog(
link,
message: name,
).show(context);
}
},
),
],
MenuItemButton(
child: Text(t.profile.share.exportConfigToClipboard),
onPressed: () async {
if (exportConfigMutation.state.isInProgress) {
return;
}
exportConfigMutation.setFuture(
ref
.read(profilesOverviewNotifierProvider.notifier)
.exportConfigToClipboard(profile),
);
},
),
],
leadingIcon: const Icon(Icons.share),
child: Text(t.profile.share.buttonText),
),
MenuItemButton(
leadingIcon: const Icon(Icons.edit),
child: Text(t.profile.edit.buttonTxt),
onPressed: () async {
await ProfileDetailsRoute(profile.id).push(context);
},
),
MenuItemButton(
leadingIcon: const Icon(Icons.delete),
child: Text(t.profile.delete.buttonTxt),
onPressed: () async {
if (deleteProfileMutation.state.isInProgress) {
return;
}
final deleteConfirmed = await showConfirmationDialog(
context,
title: t.profile.delete.buttonTxt,
message: t.profile.delete.confirmationMsg,
);
if (deleteConfirmed) {
deleteProfileMutation.setFuture(
ref
.read(profilesOverviewNotifierProvider.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: [
Directionality(
textDirection: TextDirection.ltr,
child: Flexible(
child: Text(
subInfo.total > 10 * 1099511627776 //10TB
? "∞ GiB"
: subInfo.consumption.sizeOf(subInfo.total),
semanticsLabel:
t.profile.subscription.remainingTrafficSemanticLabel(
consumed: subInfo.consumption.sizeGB(),
total: subInfo.total.sizeGB(),
),
style: theme.textTheme.bodySmall,
overflow: TextOverflow.ellipsis,
),
),
),
Flexible(
child: Text(
remaining.$1,
style: theme.textTheme.bodySmall?.copyWith(color: remaining.$2),
overflow: TextOverflow.ellipsis,
),
),
],
);
}
}
// 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],
),
);
}
}