Refactor profiles
This commit is contained in:
251
lib/features/profile/add/add_profile_modal.dart
Normal file
251
lib/features/profile/add/add_profile_modal.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
85
lib/features/profile/data/profile_data_mapper.dart
Normal file
85
lib/features/profile/data/profile_data_mapper.dart
Normal 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,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
32
lib/features/profile/data/profile_data_providers.dart
Normal file
32
lib/features/profile/data/profile_data_providers.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
133
lib/features/profile/data/profile_data_source.dart
Normal file
133
lib/features/profile/data/profile_data_source.dart
Normal 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();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
105
lib/features/profile/data/profile_parser.dart
Normal file
105
lib/features/profile/data/profile_parser.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
17
lib/features/profile/data/profile_path_resolver.dart
Normal file
17
lib/features/profile/data/profile_path_resolver.dart
Normal 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");
|
||||
}
|
||||
392
lib/features/profile/data/profile_repository.dart
Normal file
392
lib/features/profile/data/profile_repository.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
178
lib/features/profile/details/profile_details_notifier.dart
Normal file
178
lib/features/profile/details/profile_details_notifier.dart
Normal 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();
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
278
lib/features/profile/details/profile_details_page.dart
Normal file
278
lib/features/profile/details/profile_details_page.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
22
lib/features/profile/details/profile_details_state.dart
Normal file
22
lib/features/profile/details/profile_details_state.dart
Normal 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;
|
||||
}
|
||||
57
lib/features/profile/model/profile_entity.dart
Normal file
57
lib/features/profile/model/profile_entity.dart
Normal 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());
|
||||
}
|
||||
47
lib/features/profile/model/profile_failure.dart
Normal file
47
lib/features/profile/model/profile_failure.dart
Normal 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
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
21
lib/features/profile/model/profile_sort_enum.dart
Normal file
21
lib/features/profile/model/profile_sort_enum.dart
Normal 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 }
|
||||
31
lib/features/profile/notifier/active_profile_notifier.dart
Normal file
31
lib/features/profile/notifier/active_profile_notifier.dart
Normal 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));
|
||||
}
|
||||
140
lib/features/profile/notifier/profile_notifier.dart
Normal file
140
lib/features/profile/notifier/profile_notifier.dart
Normal 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();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
92
lib/features/profile/notifier/profiles_update_notifier.dart
Normal file
92
lib/features/profile/notifier/profiles_update_notifier.dart
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
140
lib/features/profile/overview/profiles_overview_page.dart
Normal file
140
lib/features/profile/overview/profiles_overview_page.dart
Normal 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,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
439
lib/features/profile/widget/profile_tile.dart
Normal file
439
lib/features/profile/widget/profile_tile.dart
Normal 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],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user