mirror of
https://github.com/ReVanced/revanced-manager.git
synced 2026-01-27 21:11:01 +00:00
feat: Add patch options (#1354)
This commit is contained in:
@@ -9,6 +9,8 @@ import 'package:revanced_manager/ui/views/home/home_viewmodel.dart';
|
||||
import 'package:revanced_manager/ui/views/installer/installer_view.dart';
|
||||
import 'package:revanced_manager/ui/views/navigation/navigation_view.dart';
|
||||
import 'package:revanced_manager/ui/views/navigation/navigation_viewmodel.dart';
|
||||
import 'package:revanced_manager/ui/views/patch_options/patch_options_view.dart';
|
||||
import 'package:revanced_manager/ui/views/patch_options/patch_options_viewmodel.dart';
|
||||
import 'package:revanced_manager/ui/views/patcher/patcher_view.dart';
|
||||
import 'package:revanced_manager/ui/views/patcher/patcher_viewmodel.dart';
|
||||
import 'package:revanced_manager/ui/views/patches_selector/patches_selector_view.dart';
|
||||
@@ -23,6 +25,7 @@ import 'package:stacked_services/stacked_services.dart';
|
||||
MaterialRoute(page: PatcherView),
|
||||
MaterialRoute(page: AppSelectorView),
|
||||
MaterialRoute(page: PatchesSelectorView),
|
||||
MaterialRoute(page: PatchOptionsView),
|
||||
MaterialRoute(page: InstallerView),
|
||||
MaterialRoute(page: SettingsView),
|
||||
MaterialRoute(page: ContributorsView),
|
||||
@@ -32,6 +35,7 @@ import 'package:stacked_services/stacked_services.dart';
|
||||
LazySingleton(classType: NavigationViewModel),
|
||||
LazySingleton(classType: HomeViewModel),
|
||||
LazySingleton(classType: PatcherViewModel),
|
||||
LazySingleton(classType: PatchOptionsViewModel),
|
||||
LazySingleton(classType: NavigationService),
|
||||
LazySingleton(classType: ManagerAPI),
|
||||
LazySingleton(classType: PatcherAPI),
|
||||
|
||||
@@ -9,6 +9,7 @@ class Patch {
|
||||
required this.description,
|
||||
required this.excluded,
|
||||
required this.compatiblePackages,
|
||||
required this.options,
|
||||
});
|
||||
|
||||
factory Patch.fromJson(Map<String, dynamic> json) => _$PatchFromJson(json);
|
||||
@@ -16,6 +17,7 @@ class Patch {
|
||||
final String? description;
|
||||
final bool excluded;
|
||||
final List<Package> compatiblePackages;
|
||||
final List<Option> options;
|
||||
|
||||
Map<String, dynamic> toJson() => _$PatchToJson(this);
|
||||
|
||||
@@ -33,8 +35,32 @@ class Package {
|
||||
|
||||
factory Package.fromJson(Map<String, dynamic> json) =>
|
||||
_$PackageFromJson(json);
|
||||
|
||||
final String name;
|
||||
final List<String> versions;
|
||||
|
||||
Map toJson() => _$PackageToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class Option {
|
||||
Option({
|
||||
required this.key,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.value,
|
||||
required this.required,
|
||||
required this.optionClassType,
|
||||
});
|
||||
|
||||
factory Option.fromJson(Map<String, dynamic> json) => _$OptionFromJson(json);
|
||||
|
||||
final String key;
|
||||
final String title;
|
||||
final String description;
|
||||
dynamic value;
|
||||
final bool required;
|
||||
final String optionClassType;
|
||||
|
||||
Map toJson() => _$OptionToJson(this);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ class ManagerAPI {
|
||||
final String cliRepo = 'revanced-cli';
|
||||
late SharedPreferences _prefs;
|
||||
List<Patch> patches = [];
|
||||
List<Option> modifiedOptions = [];
|
||||
List<Option> options = [];
|
||||
Patch? selectedPatch;
|
||||
BuildContext? ctx;
|
||||
bool isRooted = false;
|
||||
String storedPatchesFile = '/selected-patches.json';
|
||||
String keystoreFile =
|
||||
@@ -182,6 +186,29 @@ class ManagerAPI {
|
||||
await _prefs.setStringList('usedPatches-$packageName', patchesJson);
|
||||
}
|
||||
|
||||
Option? getPatchOption(String packageName, String patchName, String key) {
|
||||
final String? optionJson =
|
||||
_prefs.getString('patchOption-$packageName-$patchName-$key');
|
||||
if (optionJson != null) {
|
||||
final Option option = Option.fromJson(jsonDecode(optionJson));
|
||||
return option;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void setPatchOption(Option option, String patchName, String packageName) {
|
||||
final String optionJson = jsonEncode(option.toJson());
|
||||
_prefs.setString(
|
||||
'patchOption-$packageName-$patchName-${option.key}',
|
||||
optionJson,
|
||||
);
|
||||
}
|
||||
|
||||
void clearPatchOption(String packageName, String patchName, String key) {
|
||||
_prefs.remove('patchOption-$packageName-$patchName-$key');
|
||||
}
|
||||
|
||||
String getIntegrationsRepo() {
|
||||
return _prefs.getString('integrationsRepo') ?? defaultIntegrationsRepo;
|
||||
}
|
||||
@@ -314,7 +341,6 @@ class ManagerAPI {
|
||||
Directory('${appCache.path}/cache').createTempSync('tmp-');
|
||||
final Directory cacheDir = Directory('${workDir.path}/cache');
|
||||
cacheDir.createSync();
|
||||
|
||||
if (patchBundleFile != null) {
|
||||
try {
|
||||
final String patchesJson = await PatcherAPI.patcherChannel.invokeMethod(
|
||||
@@ -324,7 +350,6 @@ class ManagerAPI {
|
||||
'cacheDirPath': cacheDir.path,
|
||||
},
|
||||
);
|
||||
|
||||
final List<dynamic> patchesJsonList = jsonDecode(patchesJson);
|
||||
patches = patchesJsonList
|
||||
.map((patchJson) => Patch.fromJson(patchJson))
|
||||
@@ -336,6 +361,7 @@ class ManagerAPI {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return List.empty();
|
||||
}
|
||||
|
||||
@@ -596,7 +622,7 @@ class ManagerAPI {
|
||||
|
||||
// Remove apps that are not installed anymore.
|
||||
final List<PatchedApplication> toRemove =
|
||||
await getAppsToRemove(patchedApps);
|
||||
await getAppsToRemove(patchedApps);
|
||||
patchedApps.removeWhere((a) => toRemove.contains(a));
|
||||
|
||||
// Determine all apps that are installed by mounting.
|
||||
@@ -688,6 +714,14 @@ class ManagerAPI {
|
||||
return jsonDecode(string);
|
||||
}
|
||||
|
||||
void resetAllOptions() {
|
||||
_prefs.getKeys().where((key) => key.startsWith('patchOption-')).forEach(
|
||||
(key) {
|
||||
_prefs.remove(key);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> resetLastSelectedPatches() async {
|
||||
final File selectedPatchesFile = File(storedPatchesFile);
|
||||
if (selectedPatchesFile.existsSync()) {
|
||||
|
||||
@@ -18,7 +18,7 @@ import 'package:share_extend/share_extend.dart';
|
||||
@lazySingleton
|
||||
class PatcherAPI {
|
||||
static const patcherChannel =
|
||||
MethodChannel('app.revanced.manager.flutter/patcher');
|
||||
MethodChannel('app.revanced.manager.flutter/patcher');
|
||||
final ManagerAPI _managerAPI = locator<ManagerAPI>();
|
||||
final RootAPI _rootAPI = RootAPI();
|
||||
late Directory _dataDir;
|
||||
@@ -79,8 +79,7 @@ class PatcherAPI {
|
||||
}
|
||||
|
||||
Future<List<ApplicationWithIcon>> getFilteredInstalledApps(
|
||||
bool showUniversalPatches,
|
||||
) async {
|
||||
bool showUniversalPatches,) async {
|
||||
final List<ApplicationWithIcon> filteredApps = [];
|
||||
final bool allAppsIncluded =
|
||||
_universalPatches.isNotEmpty && showUniversalPatches;
|
||||
@@ -122,11 +121,11 @@ class PatcherAPI {
|
||||
final List<Patch> patches = _patches
|
||||
.where(
|
||||
(patch) =>
|
||||
patch.compatiblePackages.isEmpty ||
|
||||
!patch.name.contains('settings') &&
|
||||
patch.compatiblePackages
|
||||
.any((pack) => pack.name == packageName),
|
||||
)
|
||||
patch.compatiblePackages.isEmpty ||
|
||||
!patch.name.contains('settings') &&
|
||||
patch.compatiblePackages
|
||||
.any((pack) => pack.name == packageName),
|
||||
)
|
||||
.toList();
|
||||
if (!_managerAPI.areUniversalPatchesEnabled()) {
|
||||
filteredPatches[packageName] = patches
|
||||
@@ -138,20 +137,29 @@ class PatcherAPI {
|
||||
return filteredPatches[packageName];
|
||||
}
|
||||
|
||||
Future<List<Patch>> getAppliedPatches(
|
||||
List<String> appliedPatches,
|
||||
) async {
|
||||
Future<List<Patch>> getAppliedPatches(List<String> appliedPatches,) async {
|
||||
return _patches
|
||||
.where((patch) => appliedPatches.contains(patch.name))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> runPatcher(
|
||||
String packageName,
|
||||
String apkFilePath,
|
||||
List<Patch> selectedPatches,
|
||||
) async {
|
||||
Future<void> runPatcher(String packageName,
|
||||
String apkFilePath,
|
||||
List<Patch> selectedPatches,) async {
|
||||
final File? integrationsFile = await _managerAPI.downloadIntegrations();
|
||||
final Map<String, Map<String, dynamic>> options = {};
|
||||
for (final patch in selectedPatches) {
|
||||
if (patch.options.isNotEmpty) {
|
||||
final Map<String, dynamic> patchOptions = {};
|
||||
for (final option in patch.options) {
|
||||
final patchOption = _managerAPI.getPatchOption(packageName, patch.name, option.key);
|
||||
if (patchOption != null) {
|
||||
patchOptions[patchOption.key] = patchOption.value;
|
||||
}
|
||||
}
|
||||
options[patch.name] = patchOptions;
|
||||
}
|
||||
}
|
||||
|
||||
if (integrationsFile != null) {
|
||||
_dataDir.createSync();
|
||||
@@ -163,6 +171,7 @@ class PatcherAPI {
|
||||
final Directory cacheDir = Directory('${workDir.path}/cache');
|
||||
cacheDir.createSync();
|
||||
final String originalFilePath = apkFilePath;
|
||||
|
||||
try {
|
||||
await patcherChannel.invokeMethod(
|
||||
'runPatcher',
|
||||
@@ -173,6 +182,7 @@ class PatcherAPI {
|
||||
'outFilePath': outFile!.path,
|
||||
'integrationsPath': integrationsFile.path,
|
||||
'selectedPatches': selectedPatches.map((p) => p.name).toList(),
|
||||
'options': options,
|
||||
'cacheDirPath': cacheDir.path,
|
||||
'keyStoreFilePath': _keyStoreFile.path,
|
||||
'keystorePassword': _managerAPI.getKeystorePassword(),
|
||||
@@ -184,131 +194,131 @@ class PatcherAPI {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stopPatcher() async {
|
||||
try {
|
||||
await patcherChannel.invokeMethod('stopPatcher');
|
||||
} on Exception catch (e) {
|
||||
if (kDebugMode) {
|
||||
print(e);
|
||||
}
|
||||
Future<void> stopPatcher() async {
|
||||
try {
|
||||
await patcherChannel.invokeMethod('stopPatcher');
|
||||
} on Exception catch (e) {
|
||||
if (kDebugMode) {
|
||||
print(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> installPatchedFile(PatchedApplication patchedApp) async {
|
||||
if (outFile != null) {
|
||||
try {
|
||||
if (patchedApp.isRooted) {
|
||||
final bool hasRootPermissions = await _rootAPI.hasRootPermissions();
|
||||
if (hasRootPermissions) {
|
||||
return _rootAPI.installApp(
|
||||
patchedApp.packageName,
|
||||
patchedApp.apkFilePath,
|
||||
outFile!.path,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
final install = await InstallPlugin.installApk(outFile!.path);
|
||||
return install['isSuccess'];
|
||||
}
|
||||
} on Exception catch (e) {
|
||||
if (kDebugMode) {
|
||||
print(e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void exportPatchedFile(String appName, String version) {
|
||||
try {
|
||||
if (outFile != null) {
|
||||
final String newName = _getFileName(appName, version);
|
||||
CRFileSaver.saveFileWithDialog(
|
||||
SaveFileDialogParams(
|
||||
sourceFilePath: outFile!.path,
|
||||
destinationFileName: newName,
|
||||
),
|
||||
);
|
||||
}
|
||||
} on Exception catch (e) {
|
||||
if (kDebugMode) {
|
||||
print(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void sharePatchedFile(String appName, String version) {
|
||||
try {
|
||||
if (outFile != null) {
|
||||
final String newName = _getFileName(appName, version);
|
||||
final int lastSeparator = outFile!.path.lastIndexOf('/');
|
||||
final String newPath =
|
||||
outFile!.path.substring(0, lastSeparator + 1) + newName;
|
||||
final File shareFile = outFile!.copySync(newPath);
|
||||
ShareExtend.share(shareFile.path, 'file');
|
||||
}
|
||||
} on Exception catch (e) {
|
||||
if (kDebugMode) {
|
||||
print(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _getFileName(String appName, String version) {
|
||||
final String prefix = appName.toLowerCase().replaceAll(' ', '-');
|
||||
final String newName = '$prefix-revanced_v$version.apk';
|
||||
return newName;
|
||||
}
|
||||
|
||||
Future<void> exportPatcherLog(String logs) async {
|
||||
final Directory appCache = await getTemporaryDirectory();
|
||||
final Directory logDir = Directory('${appCache.path}/logs');
|
||||
logDir.createSync();
|
||||
final String dateTime = DateTime.now()
|
||||
.toIso8601String()
|
||||
.replaceAll('-', '')
|
||||
.replaceAll(':', '')
|
||||
.replaceAll('T', '')
|
||||
.replaceAll('.', '');
|
||||
final String fileName = 'revanced-manager_patcher_$dateTime.txt';
|
||||
final File log = File('${logDir.path}/$fileName');
|
||||
log.writeAsStringSync(logs);
|
||||
CRFileSaver.saveFileWithDialog(
|
||||
SaveFileDialogParams(
|
||||
sourceFilePath: log.path,
|
||||
destinationFileName: fileName,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String getSuggestedVersion(String packageName) {
|
||||
final Map<String, int> versions = {};
|
||||
for (final Patch patch in _patches) {
|
||||
final Package? package = patch.compatiblePackages.firstWhereOrNull(
|
||||
(pack) => pack.name == packageName,
|
||||
);
|
||||
if (package != null) {
|
||||
for (final String version in package.versions) {
|
||||
versions.update(
|
||||
version,
|
||||
(value) => versions[version]! + 1,
|
||||
ifAbsent: () => 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (versions.isNotEmpty) {
|
||||
final entries = versions.entries.toList()
|
||||
..sort((a, b) => a.value.compareTo(b.value));
|
||||
versions
|
||||
..clear()
|
||||
..addEntries(entries);
|
||||
versions.removeWhere((key, value) => value != versions.values.last);
|
||||
return (versions.keys.toList()..sort()).last;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> installPatchedFile(PatchedApplication patchedApp) async {
|
||||
if (outFile != null) {
|
||||
try {
|
||||
if (patchedApp.isRooted) {
|
||||
final bool hasRootPermissions = await _rootAPI.hasRootPermissions();
|
||||
if (hasRootPermissions) {
|
||||
return _rootAPI.installApp(
|
||||
patchedApp.packageName,
|
||||
patchedApp.apkFilePath,
|
||||
outFile!.path,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
final install = await InstallPlugin.installApk(outFile!.path);
|
||||
return install['isSuccess'];
|
||||
}
|
||||
} on Exception catch (e) {
|
||||
if (kDebugMode) {
|
||||
print(e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void exportPatchedFile(String appName, String version) {
|
||||
try {
|
||||
if (outFile != null) {
|
||||
final String newName = _getFileName(appName, version);
|
||||
CRFileSaver.saveFileWithDialog(
|
||||
SaveFileDialogParams(
|
||||
sourceFilePath: outFile!.path,
|
||||
destinationFileName: newName,
|
||||
),
|
||||
);
|
||||
}
|
||||
} on Exception catch (e) {
|
||||
if (kDebugMode) {
|
||||
print(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void sharePatchedFile(String appName, String version) {
|
||||
try {
|
||||
if (outFile != null) {
|
||||
final String newName = _getFileName(appName, version);
|
||||
final int lastSeparator = outFile!.path.lastIndexOf('/');
|
||||
final String newPath =
|
||||
outFile!.path.substring(0, lastSeparator + 1) + newName;
|
||||
final File shareFile = outFile!.copySync(newPath);
|
||||
ShareExtend.share(shareFile.path, 'file');
|
||||
}
|
||||
} on Exception catch (e) {
|
||||
if (kDebugMode) {
|
||||
print(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _getFileName(String appName, String version) {
|
||||
final String prefix = appName.toLowerCase().replaceAll(' ', '-');
|
||||
final String newName = '$prefix-revanced_v$version.apk';
|
||||
return newName;
|
||||
}
|
||||
|
||||
Future<void> exportPatcherLog(String logs) async {
|
||||
final Directory appCache = await getTemporaryDirectory();
|
||||
final Directory logDir = Directory('${appCache.path}/logs');
|
||||
logDir.createSync();
|
||||
final String dateTime = DateTime.now()
|
||||
.toIso8601String()
|
||||
.replaceAll('-', '')
|
||||
.replaceAll(':', '')
|
||||
.replaceAll('T', '')
|
||||
.replaceAll('.', '');
|
||||
final String fileName = 'revanced-manager_patcher_$dateTime.txt';
|
||||
final File log = File('${logDir.path}/$fileName');
|
||||
log.writeAsStringSync(logs);
|
||||
CRFileSaver.saveFileWithDialog(
|
||||
SaveFileDialogParams(
|
||||
sourceFilePath: log.path,
|
||||
destinationFileName: fileName,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String getSuggestedVersion(String packageName) {
|
||||
final Map<String, int> versions = {};
|
||||
for (final Patch patch in _patches) {
|
||||
final Package? package = patch.compatiblePackages.firstWhereOrNull(
|
||||
(pack) => pack.name == packageName,
|
||||
);
|
||||
if (package != null) {
|
||||
for (final String version in package.versions) {
|
||||
versions.update(
|
||||
version,
|
||||
(value) => versions[version]! + 1,
|
||||
ifAbsent: () => 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (versions.isNotEmpty) {
|
||||
final entries = versions.entries.toList()
|
||||
..sort((a, b) => a.value.compareTo(b.value));
|
||||
versions
|
||||
..clear()
|
||||
..addEntries(entries);
|
||||
versions.removeWhere((key, value) => value != versions.values.last);
|
||||
return (versions.keys.toList()
|
||||
..sort()).last;
|
||||
}
|
||||
return '';
|
||||
}}
|
||||
|
||||
@@ -23,7 +23,6 @@ class _AppSelectorViewState extends State<AppSelectorView> {
|
||||
onViewModelReady: (model) => model.initialize(),
|
||||
viewModelBuilder: () => AppSelectorViewModel(),
|
||||
builder: (context, model, child) => Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
label: I18nText('appSelectorView.storageButton'),
|
||||
icon: const Icon(Icons.sd_storage),
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:revanced_manager/services/patcher_api.dart';
|
||||
import 'package:revanced_manager/services/toast.dart';
|
||||
import 'package:revanced_manager/ui/views/patcher/patcher_viewmodel.dart';
|
||||
import 'package:revanced_manager/ui/widgets/shared/custom_material_button.dart';
|
||||
import 'package:revanced_manager/utils/check_for_supported_patch.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
class AppSelectorViewModel extends BaseViewModel {
|
||||
@@ -78,7 +79,7 @@ class AppSelectorViewModel extends BaseViewModel {
|
||||
icon: application.icon,
|
||||
patchDate: DateTime.now(),
|
||||
);
|
||||
locator<PatcherViewModel>().loadLastSelectedPatches();
|
||||
await locator<PatcherViewModel>().loadLastSelectedPatches();
|
||||
}
|
||||
|
||||
Future<void> canSelectInstalled(
|
||||
@@ -93,10 +94,14 @@ class AppSelectorViewModel extends BaseViewModel {
|
||||
return showSelectFromStorageDialog(context);
|
||||
}
|
||||
} else if (!await checkSplitApk(packageName) || isRooted) {
|
||||
selectApp(app);
|
||||
await selectApp(app);
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
final List<Option> requiredNullOptions = getNullRequiredOptions(locator<PatcherViewModel>().selectedPatches, packageName);
|
||||
if(requiredNullOptions.isNotEmpty){
|
||||
locator<PatcherViewModel>().showRequiredOptionDialog();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ class HomeViewModel extends BaseViewModel {
|
||||
final RevancedAPI _revancedAPI = locator<RevancedAPI>();
|
||||
final Toast _toast = locator<Toast>();
|
||||
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
|
||||
DateTime? _lastUpdate;
|
||||
bool showUpdatableApps = false;
|
||||
List<PatchedApplication> patchedInstalledApps = [];
|
||||
String? _latestManagerVersion = '';
|
||||
|
||||
@@ -139,6 +139,7 @@ class InstallerViewModel extends BaseViewModel {
|
||||
}
|
||||
|
||||
Future<void> runPatcher() async {
|
||||
|
||||
try {
|
||||
await _patcherAPI.runPatcher(
|
||||
_app.packageName,
|
||||
@@ -156,9 +157,9 @@ class InstallerViewModel extends BaseViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
// Necessary to reset the state of patches by reloading them
|
||||
// in a later patching process.
|
||||
_managerAPI.patches.clear();
|
||||
// Necessary to reset the state of patches so that they
|
||||
// can be reloaded again.
|
||||
_managerAPI.patches.clear();
|
||||
await _patcherAPI.loadPatches();
|
||||
|
||||
try {
|
||||
|
||||
129
lib/ui/views/patch_options/patch_options_view.dart
Normal file
129
lib/ui/views/patch_options/patch_options_view.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_i18n/flutter_i18n.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:revanced_manager/models/patch.dart';
|
||||
import 'package:revanced_manager/ui/views/patch_options/patch_options_viewmodel.dart';
|
||||
import 'package:revanced_manager/ui/widgets/patchesSelectorView/patch_options_fields.dart';
|
||||
import 'package:revanced_manager/ui/widgets/shared/custom_material_button.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
class PatchOptionsView extends StatelessWidget {
|
||||
const PatchOptionsView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<PatchOptionsViewModel>.reactive(
|
||||
onViewModelReady: (model) => model.initialize(),
|
||||
viewModelBuilder: () => PatchOptionsViewModel(),
|
||||
builder: (context, model, child) => GestureDetector(
|
||||
onTap: () => FocusScope.of(context).unfocus(),
|
||||
child: Scaffold(
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () async {
|
||||
final bool saved = model.saveOptions(context);
|
||||
if (saved && context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
label: I18nText('patchOptionsView.saveOptions'),
|
||||
icon: const Icon(Icons.save),
|
||||
),
|
||||
body: CustomScrollView(
|
||||
slivers: <Widget>[
|
||||
SliverAppBar(
|
||||
title: I18nText(
|
||||
'patchOptionsView.viewTitle',
|
||||
child: Text(
|
||||
'',
|
||||
style: GoogleFonts.inter(
|
||||
color: Theme.of(context).textTheme.titleLarge!.color,
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
model.resetOptions();
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.history,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
children: [
|
||||
for (final Option option in model.visibleOptions)
|
||||
if (option.optionClassType == 'StringPatchOption' ||
|
||||
option.optionClassType == 'IntPatchOption')
|
||||
IntAndStringPatchOption(
|
||||
patchOption: option,
|
||||
removeOption: (option) {
|
||||
model.removeOption(option);
|
||||
},
|
||||
onChanged: (value, option) {
|
||||
model.modifyOptions(value, option);
|
||||
},
|
||||
)
|
||||
else if (option.optionClassType == 'BooleanPatchOption')
|
||||
BooleanPatchOption(
|
||||
patchOption: option,
|
||||
removeOption: (option) {
|
||||
model.removeOption(option);
|
||||
},
|
||||
onChanged: (value, option) {
|
||||
model.modifyOptions(value, option);
|
||||
},
|
||||
)
|
||||
else if (option.optionClassType ==
|
||||
'StringListPatchOption' ||
|
||||
option.optionClassType == 'IntListPatchOption' ||
|
||||
option.optionClassType == 'LongListPatchOption')
|
||||
IntStringLongListPatchOption(
|
||||
patchOption: option,
|
||||
removeOption: (option) {
|
||||
model.removeOption(option);
|
||||
},
|
||||
onChanged: (value, option) {
|
||||
model.modifyOptions(value, option);
|
||||
},
|
||||
)
|
||||
else
|
||||
UnsupportedPatchOption(
|
||||
patchOption: option,
|
||||
),
|
||||
if (model.visibleOptions.length !=
|
||||
model.options.length) ...[
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
CustomMaterialButton(
|
||||
onPressed: () {
|
||||
model.showAddOptionDialog(context);
|
||||
},
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.add),
|
||||
I18nText('patchOptionsView.addOptions'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(
|
||||
height: 80,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
258
lib/ui/views/patch_options/patch_options_viewmodel.dart
Normal file
258
lib/ui/views/patch_options/patch_options_viewmodel.dart
Normal file
@@ -0,0 +1,258 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_i18n/widgets/I18nText.dart';
|
||||
import 'package:revanced_manager/app/app.locator.dart';
|
||||
import 'package:revanced_manager/models/patch.dart';
|
||||
import 'package:revanced_manager/services/manager_api.dart';
|
||||
import 'package:revanced_manager/ui/views/patcher/patcher_viewmodel.dart';
|
||||
import 'package:revanced_manager/ui/views/patches_selector/patches_selector_viewmodel.dart';
|
||||
import 'package:revanced_manager/ui/widgets/shared/custom_card.dart';
|
||||
import 'package:revanced_manager/ui/widgets/shared/custom_material_button.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
class PatchOptionsViewModel extends BaseViewModel {
|
||||
final ManagerAPI _managerAPI = locator<ManagerAPI>();
|
||||
final String selectedApp =
|
||||
locator<PatcherViewModel>().selectedApp!.packageName;
|
||||
List<Option> options = [];
|
||||
List<Option> savedOptions = [];
|
||||
List<Option> visibleOptions = [];
|
||||
|
||||
Future<void> initialize() async {
|
||||
options = getDefaultOptions();
|
||||
for (final Option option in options) {
|
||||
final Option? savedOption = _managerAPI.getPatchOption(
|
||||
selectedApp,
|
||||
_managerAPI.selectedPatch!.name,
|
||||
option.key,
|
||||
);
|
||||
if (savedOption != null) {
|
||||
savedOptions.add(savedOption);
|
||||
}
|
||||
}
|
||||
if (savedOptions.isNotEmpty) {
|
||||
visibleOptions = [
|
||||
...savedOptions,
|
||||
...options
|
||||
.where(
|
||||
(option) =>
|
||||
option.required &&
|
||||
!savedOptions.any((sOption) => sOption.key == option.key),
|
||||
)
|
||||
.toList(),
|
||||
];
|
||||
} else {
|
||||
visibleOptions = [
|
||||
...options.where((option) => option.required).toList(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
void addOption(Option option) {
|
||||
visibleOptions.add(option);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void removeOption(Option option) {
|
||||
visibleOptions.removeWhere((vOption) => vOption.key == option.key);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool saveOptions(BuildContext context) {
|
||||
final List<Option> requiredNullOptions = [];
|
||||
for (final Option option in options) {
|
||||
if (!visibleOptions.any((vOption) => vOption.key == option.key)) {
|
||||
_managerAPI.clearPatchOption(
|
||||
selectedApp, _managerAPI.selectedPatch!.name, option.key);
|
||||
}
|
||||
}
|
||||
for (final Option option in visibleOptions) {
|
||||
if (option.required && option.value == null) {
|
||||
requiredNullOptions.add(option);
|
||||
} else {
|
||||
_managerAPI.setPatchOption(
|
||||
option, _managerAPI.selectedPatch!.name, selectedApp);
|
||||
}
|
||||
}
|
||||
if (requiredNullOptions.isNotEmpty) {
|
||||
showRequiredOptionNullDialog(
|
||||
context,
|
||||
requiredNullOptions,
|
||||
_managerAPI,
|
||||
selectedApp,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void modifyOptions(dynamic value, Option option) {
|
||||
final Option modifiedOption = Option(
|
||||
title: option.title,
|
||||
description: option.description,
|
||||
optionClassType: option.optionClassType,
|
||||
value: value,
|
||||
required: option.required,
|
||||
key: option.key,
|
||||
);
|
||||
visibleOptions[visibleOptions
|
||||
.indexWhere((vOption) => vOption.key == option.key)] = modifiedOption;
|
||||
_managerAPI.modifiedOptions
|
||||
.removeWhere((mOption) => mOption.key == option.key);
|
||||
_managerAPI.modifiedOptions.add(modifiedOption);
|
||||
}
|
||||
|
||||
List<Option> getDefaultOptions() {
|
||||
final List<Option> defaultOptions = [];
|
||||
for (final option in _managerAPI.options) {
|
||||
final Option defaultOption = Option(
|
||||
title: option.title,
|
||||
description: option.description,
|
||||
optionClassType: option.optionClassType,
|
||||
value: option.value is List ? option.value.toList() : option.value,
|
||||
required: option.required,
|
||||
key: option.key,
|
||||
);
|
||||
defaultOptions.add(defaultOption);
|
||||
}
|
||||
return defaultOptions;
|
||||
}
|
||||
|
||||
void resetOptions() {
|
||||
_managerAPI.modifiedOptions.clear();
|
||||
visibleOptions =
|
||||
getDefaultOptions().where((option) => option.required).toList();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> showAddOptionDialog(BuildContext context) async {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
I18nText(
|
||||
'patchOptionsView.addOptions',
|
||||
),
|
||||
Text(
|
||||
'',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
CustomMaterialButton(
|
||||
label: I18nText('okButton'),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
contentPadding: const EdgeInsets.all(8),
|
||||
content: Wrap(
|
||||
spacing: 14,
|
||||
runSpacing: 14,
|
||||
children: options
|
||||
.where(
|
||||
(option) =>
|
||||
!visibleOptions.any((vOption) => vOption.key == option.key),
|
||||
)
|
||||
.map((e) {
|
||||
return CustomCard(
|
||||
padding: const EdgeInsets.all(4),
|
||||
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
|
||||
onTap: () {
|
||||
addOption(e);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
e.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
e.description,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showRequiredOptionNullDialog(
|
||||
BuildContext context,
|
||||
List<Option> options,
|
||||
ManagerAPI managerAPI,
|
||||
String selectedApp,
|
||||
) async {
|
||||
final List<String> optionsTitles = [];
|
||||
for (final option in options) {
|
||||
optionsTitles.add('• ${option.title}');
|
||||
}
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
|
||||
title: I18nText('notice'),
|
||||
actions: [
|
||||
CustomMaterialButton(
|
||||
isFilled: false,
|
||||
label: I18nText(
|
||||
'patchOptionsView.deselectPatch',
|
||||
),
|
||||
onPressed: () async {
|
||||
if (managerAPI.isPatchesChangeEnabled()) {
|
||||
locator<PatcherViewModel>()
|
||||
.selectedPatches
|
||||
.remove(managerAPI.selectedPatch);
|
||||
locator<PatcherViewModel>().notifyListeners();
|
||||
for (final option in options) {
|
||||
managerAPI.clearPatchOption(
|
||||
selectedApp, managerAPI.selectedPatch!.name, option.key);
|
||||
}
|
||||
Navigator.of(context)
|
||||
..pop()
|
||||
..pop()
|
||||
..pop();
|
||||
} else {
|
||||
PatchesSelectorViewModel().showPatchesChangeDialog(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
CustomMaterialButton(
|
||||
label: I18nText('okButton'),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
content: I18nText(
|
||||
'patchOptionsView.requiredOptionNull',
|
||||
translationParams: {
|
||||
'options': optionsTitles.join('\n'),
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,14 @@ class PatcherView extends StatelessWidget {
|
||||
child: FloatingActionButton.extended(
|
||||
label: I18nText('patcherView.patchButton'),
|
||||
icon: const Icon(Icons.build),
|
||||
onPressed: () => model.showRemovedPatchesDialog(context),
|
||||
onPressed: () async{
|
||||
if (model.checkRequiredPatchOption(context)) {
|
||||
final bool proceed = model.showRemovedPatchesDialog(context);
|
||||
if (proceed && context.mounted) {
|
||||
model.showArmv7WarningDialog(context);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
body: CustomScrollView(
|
||||
@@ -45,7 +52,10 @@ class PatcherView extends StatelessWidget {
|
||||
delegate: SliverChildListDelegate.fixed(
|
||||
<Widget>[
|
||||
AppSelectorCard(
|
||||
onPressed: () => model.navigateToAppSelector(),
|
||||
onPressed: () => {
|
||||
model.navigateToAppSelector(),
|
||||
model.ctx = context,
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Opacity(
|
||||
|
||||
@@ -21,6 +21,7 @@ class PatcherViewModel extends BaseViewModel {
|
||||
final ManagerAPI _managerAPI = locator<ManagerAPI>();
|
||||
final PatcherAPI _patcherAPI = locator<PatcherAPI>();
|
||||
PatchedApplication? selectedApp;
|
||||
BuildContext? ctx;
|
||||
List<Patch> selectedPatches = [];
|
||||
List<String> removedPatches = [];
|
||||
|
||||
@@ -44,9 +45,9 @@ class PatcherViewModel extends BaseViewModel {
|
||||
return selectedApp == null;
|
||||
}
|
||||
|
||||
Future<void> showRemovedPatchesDialog(BuildContext context) async {
|
||||
bool showRemovedPatchesDialog(BuildContext context) {
|
||||
if (removedPatches.isNotEmpty) {
|
||||
return showDialog(
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: I18nText('notice'),
|
||||
@@ -59,21 +60,58 @@ class PatcherViewModel extends BaseViewModel {
|
||||
CustomMaterialButton(
|
||||
isFilled: false,
|
||||
label: I18nText('noButton'),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
CustomMaterialButton(
|
||||
label: I18nText('yesButton'),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
navigateToInstaller();
|
||||
showArmv7WarningDialog(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
showArmv7WarningDialog(context); // TODO(aabed): Find out why this is here
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool checkRequiredPatchOption(BuildContext context) {
|
||||
if (getNullRequiredOptions(selectedPatches, selectedApp!.packageName).isNotEmpty) {
|
||||
showRequiredOptionDialog(context);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void showRequiredOptionDialog([context]) {
|
||||
showDialog(
|
||||
context: context ?? ctx,
|
||||
builder: (context) => AlertDialog(
|
||||
title: I18nText('notice'),
|
||||
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
|
||||
content: I18nText('patcherView.requiredOptionDialogText'),
|
||||
actions: <Widget>[
|
||||
CustomMaterialButton(
|
||||
isFilled: false,
|
||||
label: I18nText('cancelButton'),
|
||||
onPressed: () => {
|
||||
Navigator.of(context).pop(),
|
||||
},
|
||||
),
|
||||
CustomMaterialButton(
|
||||
label: I18nText('okButton'),
|
||||
onPressed: () => {
|
||||
Navigator.pop(context),
|
||||
navigateToPatchesSelector(),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showArmv7WarningDialog(BuildContext context) async {
|
||||
@@ -163,7 +201,10 @@ class PatcherViewModel extends BaseViewModel {
|
||||
final usedPatches = _managerAPI.getUsedPatches(selectedApp!.packageName);
|
||||
for (final patch in usedPatches){
|
||||
if (!patches.any((p) => p.name == patch.name)){
|
||||
removedPatches.add('\u2022 ${patch.name}');
|
||||
removedPatches.add('• ${patch.name}');
|
||||
for (final option in patch.options) {
|
||||
_managerAPI.clearPatchOption(selectedApp!.packageName, patch.name, option.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
|
||||
@@ -37,7 +37,6 @@ class _PatchesSelectorViewState extends State<PatchesSelectorView> {
|
||||
onViewModelReady: (model) => model.initialize(),
|
||||
viewModelBuilder: () => PatchesSelectorViewModel(),
|
||||
builder: (context, model, child) => Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
floatingActionButton: Visibility(
|
||||
visible: model.patches.isNotEmpty,
|
||||
child: FloatingActionButton.extended(
|
||||
@@ -49,8 +48,10 @@ class _PatchesSelectorViewState extends State<PatchesSelectorView> {
|
||||
),
|
||||
icon: const Icon(Icons.check),
|
||||
onPressed: () {
|
||||
model.selectPatches();
|
||||
Navigator.of(context).pop();
|
||||
if (!model.areRequiredOptionsNull(context)) {
|
||||
model.selectPatches();
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -73,7 +74,10 @@ class _PatchesSelectorViewState extends State<PatchesSelectorView> {
|
||||
Icons.arrow_back,
|
||||
color: Theme.of(context).textTheme.titleLarge!.color,
|
||||
),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
onPressed: () {
|
||||
model.resetSelection();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
FittedBox(
|
||||
@@ -81,7 +85,7 @@ class _PatchesSelectorViewState extends State<PatchesSelectorView> {
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(top: 12, bottom: 12),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
|
||||
const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
@@ -99,7 +103,7 @@ class _PatchesSelectorViewState extends State<PatchesSelectorView> {
|
||||
),
|
||||
CustomPopupMenu(
|
||||
onSelected: (value) =>
|
||||
{model.onMenuSelection(value, context)},
|
||||
{model.onMenuSelection(value, context)},
|
||||
children: {
|
||||
0: I18nText(
|
||||
'patchesSelectorView.loadPatchesSelection',
|
||||
@@ -188,6 +192,93 @@ class _PatchesSelectorViewState extends State<PatchesSelectorView> {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (model.newPatchExists())
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 10.0,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 10.0,
|
||||
bottom: 10.0,
|
||||
left: 5.0,
|
||||
),
|
||||
child: I18nText(
|
||||
'patchesSelectorView.newPatches',
|
||||
child: Text(
|
||||
'',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
...model.getQueriedPatches(_query).map((patch) {
|
||||
if (model.isPatchNew(patch)) {
|
||||
return PatchItem(
|
||||
name: patch.name,
|
||||
simpleName: patch.getSimpleName(),
|
||||
description: patch.description ?? '',
|
||||
packageVersion:
|
||||
model.getAppInfo().version,
|
||||
supportedPackageVersions:
|
||||
model.getSupportedVersions(patch),
|
||||
isUnsupported: !isPatchSupported(patch),
|
||||
isChangeEnabled:
|
||||
_managerAPI.isPatchesChangeEnabled(),
|
||||
hasUnsupportedPatchOption:
|
||||
hasUnsupportedRequiredOption(
|
||||
patch.options,
|
||||
patch,
|
||||
),
|
||||
options: patch.options,
|
||||
isSelected: model.isSelected(patch),
|
||||
navigateToOptions: (options) =>
|
||||
model.navigateToPatchOptions(
|
||||
options,
|
||||
patch,
|
||||
),
|
||||
onChanged: (value) => model.selectPatch(
|
||||
patch,
|
||||
value,
|
||||
context,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
}),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 10.0,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 10.0,
|
||||
bottom: 10.0,
|
||||
left: 5.0,
|
||||
),
|
||||
child: I18nText(
|
||||
'patchesSelectorView.patches',
|
||||
child: Text(
|
||||
'',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
...model.getQueriedPatches(_query).map(
|
||||
(patch) {
|
||||
if (patch.compatiblePackages.isNotEmpty) {
|
||||
@@ -201,13 +292,21 @@ class _PatchesSelectorViewState extends State<PatchesSelectorView> {
|
||||
isUnsupported: !isPatchSupported(patch),
|
||||
isChangeEnabled:
|
||||
_managerAPI.isPatchesChangeEnabled(),
|
||||
isNew: model.isPatchNew(
|
||||
patch,
|
||||
model.getAppInfo().packageName,
|
||||
),
|
||||
hasUnsupportedPatchOption:
|
||||
hasUnsupportedRequiredOption(
|
||||
patch.options, patch),
|
||||
options: patch.options,
|
||||
isSelected: model.isSelected(patch),
|
||||
onChanged: (value) =>
|
||||
model.selectPatch(patch, value, context),
|
||||
navigateToOptions: (options) =>
|
||||
model.navigateToPatchOptions(
|
||||
options,
|
||||
patch,
|
||||
),
|
||||
onChanged: (value) => model.selectPatch(
|
||||
patch,
|
||||
value,
|
||||
context,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
@@ -254,8 +353,18 @@ class _PatchesSelectorViewState extends State<PatchesSelectorView> {
|
||||
isUnsupported: !isPatchSupported(patch),
|
||||
isChangeEnabled:
|
||||
_managerAPI.isPatchesChangeEnabled(),
|
||||
isNew: false,
|
||||
hasUnsupportedPatchOption:
|
||||
hasUnsupportedRequiredOption(
|
||||
patch.options,
|
||||
patch,
|
||||
),
|
||||
options: patch.options,
|
||||
isSelected: model.isSelected(patch),
|
||||
navigateToOptions: (options) =>
|
||||
model.navigateToPatchOptions(
|
||||
options,
|
||||
patch,
|
||||
),
|
||||
onChanged: (value) => model.selectPatch(
|
||||
patch,
|
||||
value,
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_i18n/widgets/I18nText.dart';
|
||||
import 'package:revanced_manager/app/app.locator.dart';
|
||||
import 'package:revanced_manager/app/app.router.dart';
|
||||
import 'package:revanced_manager/models/patch.dart';
|
||||
import 'package:revanced_manager/models/patched_application.dart';
|
||||
import 'package:revanced_manager/services/manager_api.dart';
|
||||
@@ -11,11 +12,14 @@ import 'package:revanced_manager/ui/views/patcher/patcher_viewmodel.dart';
|
||||
import 'package:revanced_manager/ui/widgets/shared/custom_material_button.dart';
|
||||
import 'package:revanced_manager/utils/check_for_supported_patch.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
import 'package:stacked_services/stacked_services.dart';
|
||||
|
||||
class PatchesSelectorViewModel extends BaseViewModel {
|
||||
final PatcherAPI _patcherAPI = locator<PatcherAPI>();
|
||||
final ManagerAPI _managerAPI = locator<ManagerAPI>();
|
||||
final NavigationService _navigationService = locator<NavigationService>();
|
||||
final List<Patch> patches = [];
|
||||
final List<Patch> currentSelection = [];
|
||||
final List<Patch> selectedPatches =
|
||||
locator<PatcherViewModel>().selectedPatches;
|
||||
PatchedApplication? selectedApp = locator<PatcherViewModel>().selectedApp;
|
||||
@@ -31,14 +35,18 @@ class PatchesSelectorViewModel extends BaseViewModel {
|
||||
selectedApp!.packageName,
|
||||
),
|
||||
);
|
||||
final List<Option> requiredNullOptions =
|
||||
getNullRequiredOptions(patches, selectedApp!.packageName);
|
||||
patches.sort((a, b) {
|
||||
if (isPatchNew(a, selectedApp!.packageName) ==
|
||||
isPatchNew(b, selectedApp!.packageName)) {
|
||||
return a.name.compareTo(b.name);
|
||||
if (b.options.any((option) => requiredNullOptions.contains(option)) &&
|
||||
a.options.isEmpty) {
|
||||
return 1;
|
||||
} else {
|
||||
return isPatchNew(b, selectedApp!.packageName) ? 1 : -1;
|
||||
return a.name.compareTo(b.name);
|
||||
}
|
||||
});
|
||||
currentSelection.clear();
|
||||
currentSelection.addAll(selectedPatches);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -48,6 +56,60 @@ class PatchesSelectorViewModel extends BaseViewModel {
|
||||
);
|
||||
}
|
||||
|
||||
void navigateToPatchOptions(List<Option> setOptions, Patch patch) {
|
||||
_managerAPI.options = setOptions;
|
||||
_managerAPI.selectedPatch = patch;
|
||||
_managerAPI.modifiedOptions.clear();
|
||||
_navigationService.navigateToPatchOptionsView();
|
||||
}
|
||||
|
||||
bool areRequiredOptionsNull(BuildContext context) {
|
||||
final List<String> patchesWithNullRequiredOptions = [];
|
||||
final List<Option> requiredNullOptions =
|
||||
getNullRequiredOptions(selectedPatches, selectedApp!.packageName);
|
||||
if (requiredNullOptions.isNotEmpty) {
|
||||
for (final patch in selectedPatches) {
|
||||
for (final patchOption in patch.options) {
|
||||
if (requiredNullOptions.contains(patchOption)) {
|
||||
patchesWithNullRequiredOptions.add(patch.name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
showSetRequiredOption(context, patchesWithNullRequiredOptions);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> showSetRequiredOption(
|
||||
BuildContext context,
|
||||
List<String> patches,
|
||||
) async {
|
||||
return showDialog(
|
||||
barrierDismissible: false,
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: I18nText('notice'),
|
||||
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
|
||||
content: I18nText(
|
||||
'patchesSelectorView.setRequiredOption',
|
||||
translationParams: {
|
||||
'patches': patches.map((patch) => '• $patch').join('\n'),
|
||||
},
|
||||
),
|
||||
actions: <Widget>[
|
||||
CustomMaterialButton(
|
||||
label: I18nText('okButton'),
|
||||
onPressed: () => {
|
||||
Navigator.of(context).pop(),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void selectPatch(Patch patch, bool isSelected, BuildContext context) {
|
||||
if (_managerAPI.isPatchesChangeEnabled()) {
|
||||
if (isSelected && !selectedPatches.contains(patch)) {
|
||||
@@ -123,9 +185,19 @@ class PatchesSelectorViewModel extends BaseViewModel {
|
||||
void selectPatches() {
|
||||
locator<PatcherViewModel>().selectedPatches = selectedPatches;
|
||||
saveSelectedPatches();
|
||||
if (_managerAPI.ctx != null) {
|
||||
Navigator.pop(_managerAPI.ctx!);
|
||||
_managerAPI.ctx = null;
|
||||
}
|
||||
locator<PatcherViewModel>().notifyListeners();
|
||||
}
|
||||
|
||||
void resetSelection() {
|
||||
selectedPatches.clear();
|
||||
selectedPatches.addAll(currentSelection);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> getPatchesVersion() async {
|
||||
patchesVersion = await _managerAPI.getCurrentPatchesVersion();
|
||||
}
|
||||
@@ -153,8 +225,9 @@ class PatchesSelectorViewModel extends BaseViewModel {
|
||||
return locator<PatcherViewModel>().selectedApp!;
|
||||
}
|
||||
|
||||
bool isPatchNew(Patch patch, String packageName) {
|
||||
final List<Patch> savedPatches = _managerAPI.getSavedPatches(packageName);
|
||||
bool isPatchNew(Patch patch) {
|
||||
final List<Patch> savedPatches =
|
||||
_managerAPI.getSavedPatches(selectedApp!.packageName);
|
||||
if (savedPatches.isEmpty) {
|
||||
return false;
|
||||
} else {
|
||||
@@ -163,6 +236,12 @@ class PatchesSelectorViewModel extends BaseViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
bool newPatchExists() {
|
||||
return patches.any(
|
||||
(patch) => isPatchNew(patch),
|
||||
);
|
||||
}
|
||||
|
||||
List<String> getSupportedVersions(Patch patch) {
|
||||
final PatchedApplication app = locator<PatcherViewModel>().selectedApp!;
|
||||
final Package? package = patch.compatiblePackages.firstWhereOrNull(
|
||||
|
||||
@@ -246,6 +246,11 @@ class SettingsViewModel extends BaseViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
void resetAllOptions() {
|
||||
_managerAPI.resetAllOptions();
|
||||
_toast.showBottom('settingsView.resetStoredOptions');
|
||||
}
|
||||
|
||||
void resetSelectedPatches() {
|
||||
_managerAPI.resetLastSelectedPatches();
|
||||
_toast.showBottom('settingsView.resetStoredPatches');
|
||||
|
||||
@@ -146,7 +146,7 @@ class AppInfoViewModel extends BaseViewModel {
|
||||
}
|
||||
|
||||
String getAppliedPatchesString(List<String> appliedPatches) {
|
||||
return '\u2022 ${appliedPatches.join('\n\u2022 ')}';
|
||||
return '• ${appliedPatches.join('\n• ')}';
|
||||
}
|
||||
|
||||
void openApp(PatchedApplication app) {
|
||||
|
||||
@@ -61,7 +61,7 @@ class PatchSelectorCard extends StatelessWidget {
|
||||
final List<Patch> selectedPatches = locator<PatcherViewModel>().selectedPatches;
|
||||
selectedPatches.sort((a, b) => a.name.compareTo(b.name));
|
||||
for (final Patch p in selectedPatches) {
|
||||
text += '\u2022 ${p.getSimpleName()}\n';
|
||||
text += '• ${p.getSimpleName()}\n';
|
||||
}
|
||||
return text.substring(0, text.length - 1);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_i18n/flutter_i18n.dart';
|
||||
import 'package:revanced_manager/app/app.locator.dart';
|
||||
import 'package:revanced_manager/models/patch.dart';
|
||||
import 'package:revanced_manager/services/manager_api.dart';
|
||||
import 'package:revanced_manager/services/toast.dart';
|
||||
import 'package:revanced_manager/ui/widgets/shared/custom_card.dart';
|
||||
@@ -16,11 +17,12 @@ class PatchItem extends StatefulWidget {
|
||||
required this.packageVersion,
|
||||
required this.supportedPackageVersions,
|
||||
required this.isUnsupported,
|
||||
required this.isNew,
|
||||
required this.hasUnsupportedPatchOption,
|
||||
required this.options,
|
||||
required this.isSelected,
|
||||
required this.onChanged,
|
||||
required this.navigateToOptions,
|
||||
required this.isChangeEnabled,
|
||||
this.child,
|
||||
}) : super(key: key);
|
||||
final String name;
|
||||
final String simpleName;
|
||||
@@ -28,11 +30,12 @@ class PatchItem extends StatefulWidget {
|
||||
final String packageVersion;
|
||||
final List<String> supportedPackageVersions;
|
||||
final bool isUnsupported;
|
||||
final bool isNew;
|
||||
final bool hasUnsupportedPatchOption;
|
||||
final List<Option> options;
|
||||
bool isSelected;
|
||||
final Function(bool) onChanged;
|
||||
final void Function(List<Option>) navigateToOptions;
|
||||
final bool isChangeEnabled;
|
||||
final Widget? child;
|
||||
final toast = locator<Toast>();
|
||||
final _managerAPI = locator<ManagerAPI>();
|
||||
|
||||
@@ -45,7 +48,8 @@ class _PatchItemState extends State<PatchItem> {
|
||||
Widget build(BuildContext context) {
|
||||
widget.isSelected = widget.isSelected &&
|
||||
(!widget.isUnsupported ||
|
||||
widget._managerAPI.areExperimentalPatchesEnabled());
|
||||
widget._managerAPI.areExperimentalPatchesEnabled()) &&
|
||||
!widget.hasUnsupportedPatchOption;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Opacity(
|
||||
@@ -54,148 +58,150 @@ class _PatchItemState extends State<PatchItem> {
|
||||
? 0.5
|
||||
: 1,
|
||||
child: CustomCard(
|
||||
padding: EdgeInsets.only(
|
||||
top: 12,
|
||||
bottom: 16,
|
||||
left: 8.0,
|
||||
right: widget.options.isNotEmpty ? 4.0 : 8.0,
|
||||
),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (widget.isUnsupported &&
|
||||
!widget._managerAPI.areExperimentalPatchesEnabled()) {
|
||||
widget.isSelected = false;
|
||||
widget.toast.showBottom('patchItem.unsupportedPatchVersion');
|
||||
} else if (widget.isChangeEnabled) {
|
||||
widget.isSelected = !widget.isSelected;
|
||||
if (widget.isUnsupported &&
|
||||
!widget._managerAPI.areExperimentalPatchesEnabled()) {
|
||||
widget.isSelected = false;
|
||||
widget.toast.showBottom('patchItem.unsupportedPatchVersion');
|
||||
} else if (widget.isChangeEnabled) {
|
||||
if (!widget.isSelected) {
|
||||
if (widget.hasUnsupportedPatchOption) {
|
||||
_showUnsupportedRequiredOptionDialog();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!widget.isUnsupported || widget._managerAPI.areExperimentalPatchesEnabled()) {
|
||||
widget.isSelected = !widget.isSelected;
|
||||
setState(() {});
|
||||
}
|
||||
if (!widget.isUnsupported ||
|
||||
widget._managerAPI.areExperimentalPatchesEnabled()) {
|
||||
widget.onChanged(widget.isSelected);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.simpleName,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.visible,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.description,
|
||||
softWrap: true,
|
||||
overflow: TextOverflow.visible,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Transform.scale(
|
||||
scale: 1.2,
|
||||
child: Checkbox(
|
||||
value: widget.isSelected,
|
||||
activeColor: Theme.of(context).colorScheme.primary,
|
||||
checkColor: Theme.of(context).colorScheme.secondaryContainer,
|
||||
side: BorderSide(
|
||||
width: 2.0,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
Transform.scale(
|
||||
scale: 1.2,
|
||||
child: Checkbox(
|
||||
value: widget.isSelected,
|
||||
activeColor: Theme.of(context).colorScheme.primary,
|
||||
checkColor:
|
||||
Theme.of(context).colorScheme.secondaryContainer,
|
||||
side: BorderSide(
|
||||
width: 2.0,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
onChanged: (newValue) {
|
||||
setState(() {
|
||||
if (widget.isUnsupported &&
|
||||
!widget._managerAPI
|
||||
.areExperimentalPatchesEnabled()) {
|
||||
widget.isSelected = false;
|
||||
widget.toast.showBottom(
|
||||
'patchItem.unsupportedPatchVersion',
|
||||
);
|
||||
} else if (widget.isChangeEnabled) {
|
||||
widget.isSelected = newValue!;
|
||||
}
|
||||
});
|
||||
if (!widget.isUnsupported || widget._managerAPI.areExperimentalPatchesEnabled()) {
|
||||
widget.onChanged(widget.isSelected);
|
||||
onChanged: (newValue) {
|
||||
if (widget.isUnsupported &&
|
||||
!widget._managerAPI.areExperimentalPatchesEnabled()) {
|
||||
widget.isSelected = false;
|
||||
widget.toast.showBottom(
|
||||
'patchItem.unsupportedPatchVersion',
|
||||
);
|
||||
} else if (widget.isChangeEnabled) {
|
||||
if (!widget.isSelected) {
|
||||
if (widget.hasUnsupportedPatchOption) {
|
||||
_showUnsupportedRequiredOptionDialog();
|
||||
return;
|
||||
}
|
||||
},
|
||||
),
|
||||
}
|
||||
widget.isSelected = newValue!;
|
||||
setState(() {});
|
||||
}
|
||||
if (!widget.isUnsupported ||
|
||||
widget._managerAPI.areExperimentalPatchesEnabled()) {
|
||||
widget.onChanged(widget.isSelected);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.simpleName,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.visible,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
widget.description,
|
||||
softWrap: true,
|
||||
overflow: TextOverflow.visible,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
if (widget.description.isNotEmpty)
|
||||
Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
if (widget.isUnsupported &&
|
||||
widget._managerAPI
|
||||
.areExperimentalPatchesEnabled())
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: TextButton.icon(
|
||||
label: I18nText('warning'),
|
||||
icon: const Icon(
|
||||
Icons.warning_amber_outlined,
|
||||
size: 20.0,
|
||||
),
|
||||
onPressed: () =>
|
||||
_showUnsupportedWarningDialog(),
|
||||
style: ButtonStyle(
|
||||
shape: MaterialStateProperty.all(
|
||||
RoundedRectangleBorder(
|
||||
borderRadius:
|
||||
BorderRadius.circular(8),
|
||||
side: BorderSide(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.secondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
backgroundColor:
|
||||
MaterialStateProperty.all(
|
||||
Colors.transparent,
|
||||
),
|
||||
foregroundColor:
|
||||
MaterialStateProperty.all(
|
||||
Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
if (widget.isUnsupported &&
|
||||
widget._managerAPI.areExperimentalPatchesEnabled())
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8, right: 8),
|
||||
child: TextButton.icon(
|
||||
label: I18nText('warning'),
|
||||
icon: const Icon(Icons.warning, size: 20.0),
|
||||
onPressed: () => _showUnsupportedWarningDialog(),
|
||||
style: ButtonStyle(
|
||||
shape: MaterialStateProperty.all(
|
||||
RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
side: BorderSide(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
backgroundColor: MaterialStateProperty.all(
|
||||
Colors.transparent,
|
||||
),
|
||||
foregroundColor: MaterialStateProperty.all(
|
||||
Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.isNew)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: TextButton.icon(
|
||||
label: I18nText('new'),
|
||||
icon: const Icon(Icons.star, size: 20.0),
|
||||
onPressed: () => _showNewPatchDialog(),
|
||||
style: ButtonStyle(
|
||||
shape: MaterialStateProperty.all(
|
||||
RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
side: BorderSide(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
backgroundColor: MaterialStateProperty.all(
|
||||
Colors.transparent,
|
||||
),
|
||||
foregroundColor: MaterialStateProperty.all(
|
||||
Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
widget.child ?? const SizedBox(),
|
||||
if (widget.options.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
onPressed: () => widget.navigateToOptions(widget.options),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -214,7 +220,7 @@ class _PatchItemState extends State<PatchItem> {
|
||||
translationParams: {
|
||||
'packageVersion': widget.packageVersion,
|
||||
'supportedVersions':
|
||||
'\u2022 ${widget.supportedPackageVersions.reversed.join('\n\u2022 ')}',
|
||||
'• ${widget.supportedPackageVersions.reversed.join('\n• ')}',
|
||||
},
|
||||
),
|
||||
actions: <Widget>[
|
||||
@@ -227,14 +233,14 @@ class _PatchItemState extends State<PatchItem> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showNewPatchDialog() {
|
||||
Future<void> _showUnsupportedRequiredOptionDialog() {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: I18nText('patchItem.newPatch'),
|
||||
title: I18nText('notice'),
|
||||
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
|
||||
content: I18nText(
|
||||
'patchItem.newPatchDialogText',
|
||||
'patchItem.unsupportedRequiredOption',
|
||||
),
|
||||
actions: <Widget>[
|
||||
CustomMaterialButton(
|
||||
|
||||
@@ -1,73 +1,387 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_i18n/flutter_i18n.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:revanced_manager/models/patch.dart';
|
||||
import 'package:revanced_manager/ui/widgets/shared/custom_card.dart';
|
||||
|
||||
class OptionsTextField extends StatelessWidget {
|
||||
const OptionsTextField({Key? key, required this.hint}) : super(key: key);
|
||||
final String hint;
|
||||
class BooleanPatchOption extends StatelessWidget {
|
||||
const BooleanPatchOption({
|
||||
super.key,
|
||||
required this.patchOption,
|
||||
required this.removeOption,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final Option patchOption;
|
||||
final void Function(Option option) removeOption;
|
||||
final void Function(dynamic value, Option option) onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final sHeight = size.height;
|
||||
final sWidth = size.width;
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(top: 12, bottom: 6),
|
||||
padding: EdgeInsets.zero,
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: sHeight * 0.05,
|
||||
maxWidth: sWidth * 1,
|
||||
final ValueNotifier patchOptionValue = ValueNotifier(patchOption.value);
|
||||
return PatchOption(
|
||||
widget: Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: patchOptionValue,
|
||||
builder: (context, value, child) {
|
||||
return Switch(
|
||||
value: value ?? false,
|
||||
onChanged: (bool value) {
|
||||
patchOptionValue.value = value;
|
||||
onChanged(value, patchOption);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
patchOption: patchOption,
|
||||
removeOption: (Option option) {
|
||||
removeOption(option);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class IntAndStringPatchOption extends StatelessWidget {
|
||||
const IntAndStringPatchOption({
|
||||
super.key,
|
||||
required this.patchOption,
|
||||
required this.removeOption,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final Option patchOption;
|
||||
final void Function(Option option) removeOption;
|
||||
final void Function(dynamic value, Option option) onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ValueNotifier patchOptionValue = ValueNotifier(patchOption.value);
|
||||
return PatchOption(
|
||||
widget: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextFieldForPatchOption(
|
||||
value: patchOption.value,
|
||||
optionType: patchOption.optionClassType,
|
||||
onChanged: (value) {
|
||||
patchOptionValue.value = value;
|
||||
onChanged(value, patchOption);
|
||||
},
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: hint,
|
||||
ValueListenableBuilder(
|
||||
valueListenable: patchOptionValue,
|
||||
builder: (context, value, child) {
|
||||
if (patchOption.required && value == null) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children:[
|
||||
const SizedBox(height: 8),
|
||||
I18nText(
|
||||
'patchOptionsView.requiredOption',
|
||||
child: Text(
|
||||
'',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return const SizedBox();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
patchOption: patchOption,
|
||||
removeOption: (Option option) {
|
||||
removeOption(option);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class IntStringLongListPatchOption extends StatelessWidget {
|
||||
const IntStringLongListPatchOption({
|
||||
super.key,
|
||||
required this.patchOption,
|
||||
required this.removeOption,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final Option patchOption;
|
||||
final void Function(Option option) removeOption;
|
||||
final void Function(dynamic value, Option option) onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final String type = patchOption.optionClassType;
|
||||
final List<dynamic> values = patchOption.value ?? [];
|
||||
final ValueNotifier patchOptionValue = ValueNotifier(values);
|
||||
return PatchOption(
|
||||
widget: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ValueListenableBuilder(
|
||||
valueListenable: patchOptionValue,
|
||||
builder: (context, value, child) {
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: value.length,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final e = values[index];
|
||||
return TextFieldForPatchOption(
|
||||
value: e.toString(),
|
||||
optionType: type,
|
||||
onChanged: (newValue) {
|
||||
values[index] = type == 'StringListPatchOption' ? newValue : type == 'IntListPatchOption' ? int.parse(newValue) : num.parse(newValue);
|
||||
onChanged(values, patchOption);
|
||||
},
|
||||
removeValue: (value) {
|
||||
patchOptionValue.value = List.from(patchOptionValue.value)..removeAt(index);
|
||||
values.removeAt(index);
|
||||
onChanged(values, patchOption);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
if (type == 'StringListPatchOption') {
|
||||
patchOptionValue.value = List.from(patchOptionValue.value)..add('');
|
||||
values.add('');
|
||||
} else {
|
||||
patchOptionValue.value = List.from(patchOptionValue.value)..add(0);
|
||||
values.add(0);
|
||||
}
|
||||
onChanged(values, patchOption);
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.add, size: 20),
|
||||
I18nText(
|
||||
'add',
|
||||
child: const Text(
|
||||
'',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
patchOption: patchOption,
|
||||
removeOption: (Option option) {
|
||||
removeOption(option);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UnsupportedPatchOption extends StatelessWidget {
|
||||
const UnsupportedPatchOption({super.key, required this.patchOption});
|
||||
final Option patchOption;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PatchOption(
|
||||
widget: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: I18nText(
|
||||
'patchOptionsView.unsupportedOption',
|
||||
child: const Text(
|
||||
'',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
patchOption: patchOption,
|
||||
removeOption: (_) {},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PatchOption extends StatelessWidget {
|
||||
const PatchOption({
|
||||
super.key,
|
||||
required this.widget,
|
||||
required this.patchOption,
|
||||
required this.removeOption,
|
||||
});
|
||||
|
||||
final Widget widget;
|
||||
final Option patchOption;
|
||||
final void Function(Option option) removeOption;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: CustomCard(
|
||||
onTap: () {},
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
patchOption.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
patchOption.description,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!patchOption.required)
|
||||
IconButton(
|
||||
onPressed: () => removeOption(patchOption),
|
||||
icon: const Icon(Icons.delete),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
widget,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OptionsFilePicker extends StatelessWidget {
|
||||
const OptionsFilePicker({Key? key, required this.optionName})
|
||||
: super(key: key);
|
||||
final String optionName;
|
||||
class TextFieldForPatchOption extends StatefulWidget {
|
||||
const TextFieldForPatchOption({
|
||||
super.key,
|
||||
required this.value,
|
||||
this.removeValue,
|
||||
required this.onChanged,
|
||||
required this.optionType,
|
||||
});
|
||||
|
||||
final String? value;
|
||||
final String optionType;
|
||||
final void Function(dynamic value)? removeValue;
|
||||
final void Function(dynamic value) onChanged;
|
||||
|
||||
@override
|
||||
State<TextFieldForPatchOption> createState() =>
|
||||
_TextFieldForPatchOptionState();
|
||||
}
|
||||
|
||||
class _TextFieldForPatchOptionState extends State<TextFieldForPatchOption> {
|
||||
final TextEditingController controller = TextEditingController();
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
I18nText(
|
||||
optionName,
|
||||
child: Text(
|
||||
'',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
final bool isStringOption = widget.optionType.contains('String');
|
||||
final bool isListOption = widget.optionType.contains('List');
|
||||
controller.text = widget.value ?? '';
|
||||
return TextFormField(
|
||||
inputFormatters: [
|
||||
if (widget.optionType.contains('Int'))
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[0-9]')),
|
||||
if (widget.optionType.contains('Long'))
|
||||
FilteringTextInputFormatter.allow(RegExp(r'^[0-9]*\.?[0-9]*')),
|
||||
],
|
||||
controller: controller,
|
||||
keyboardType: isStringOption ? TextInputType.text : TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
suffixIcon: PopupMenuButton(
|
||||
tooltip: FlutterI18n.translate(
|
||||
context,
|
||||
'patchOptionsView.tooltip',
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ButtonStyle(
|
||||
backgroundColor: MaterialStateProperty.all(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
// pick files
|
||||
},
|
||||
child: Text(
|
||||
'Select File',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).textTheme.bodyLarge?.color,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
itemBuilder: (BuildContext context) {
|
||||
return [
|
||||
if (isListOption)
|
||||
PopupMenuItem(
|
||||
value: 'remove',
|
||||
child: I18nText('remove'),
|
||||
),
|
||||
if (isStringOption && !isListOption) ...[
|
||||
PopupMenuItem(
|
||||
value: 'patchOptionsView.selectFilePath',
|
||||
child: I18nText('patchOptionsView.selectFilePath'),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'patchOptionsView.selectFolder',
|
||||
child: I18nText('patchOptionsView.selectFolder'),
|
||||
),
|
||||
],
|
||||
];
|
||||
},
|
||||
onSelected: (String selection) async {
|
||||
switch (selection) {
|
||||
case 'patchOptionsView.selectFilePath':
|
||||
final result = await FilePicker.platform.pickFiles();
|
||||
if (result != null && result.files.single.path != null) {
|
||||
controller.text = result.files.single.path.toString();
|
||||
widget.onChanged(controller.text);
|
||||
}
|
||||
break;
|
||||
case 'patchOptionsView.selectFolder':
|
||||
final result = await FilePicker.platform.getDirectoryPath();
|
||||
if (result != null) {
|
||||
controller.text = result;
|
||||
widget.onChanged(controller.text);
|
||||
}
|
||||
break;
|
||||
case 'remove':
|
||||
widget.removeValue!(widget.value);
|
||||
break;
|
||||
}
|
||||
},
|
||||
),
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
onChanged: (String value) {
|
||||
widget.onChanged(value);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,21 +94,49 @@ class SExportSection extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
subtitle: I18nText('settingsView.resetStoredPatchesHint'),
|
||||
onTap: () => _showResetStoredPatchesDialog(context),
|
||||
onTap: () => _showResetDialog(
|
||||
context,
|
||||
'settingsView.resetStoredPatchesDialogTitle',
|
||||
'settingsView.resetStoredPatchesDialogText',
|
||||
_settingsViewModel.resetSelectedPatches,
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20.0),
|
||||
title: I18nText(
|
||||
'settingsView.resetStoredOptionsLabel',
|
||||
child: const Text(
|
||||
'',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
subtitle: I18nText('settingsView.resetStoredOptionsHint'),
|
||||
onTap: () => _showResetDialog(
|
||||
context,
|
||||
'settingsView.resetStoredOptionsDialogTitle',
|
||||
'settingsView.resetStoredOptionsDialogText',
|
||||
_settingsViewModel.resetAllOptions,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showResetStoredPatchesDialog(context) {
|
||||
Future<void> _showResetDialog(
|
||||
context,
|
||||
dialogTitle,
|
||||
dialogText,
|
||||
dialogAction,
|
||||
) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: I18nText('settingsView.resetStoredPatchesDialogTitle'),
|
||||
title: I18nText(dialogTitle),
|
||||
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
|
||||
content: I18nText(
|
||||
'settingsView.resetStoredPatchesDialogText',
|
||||
),
|
||||
content: I18nText(dialogText),
|
||||
actions: <Widget>[
|
||||
CustomMaterialButton(
|
||||
isFilled: false,
|
||||
@@ -119,7 +147,7 @@ class SExportSection extends StatelessWidget {
|
||||
label: I18nText('yesButton'),
|
||||
onPressed: () => {
|
||||
Navigator.of(context).pop(),
|
||||
_settingsViewModel.resetSelectedPatches(),
|
||||
dialogAction(),
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:revanced_manager/app/app.locator.dart';
|
||||
import 'package:revanced_manager/models/patch.dart';
|
||||
import 'package:revanced_manager/models/patched_application.dart';
|
||||
import 'package:revanced_manager/services/manager_api.dart';
|
||||
import 'package:revanced_manager/ui/views/patcher/patcher_viewmodel.dart';
|
||||
|
||||
bool isPatchSupported(Patch patch) {
|
||||
@@ -12,3 +13,49 @@ bool isPatchSupported(Patch patch) {
|
||||
(pack.versions.isEmpty || pack.versions.contains(app.version)),
|
||||
);
|
||||
}
|
||||
|
||||
bool hasUnsupportedRequiredOption(List<Option> options, Patch patch) {
|
||||
final List<String> requiredOptionsType = [];
|
||||
final List<String> supportedOptionsType = [
|
||||
'StringPatchOption',
|
||||
'BooleanPatchOption',
|
||||
'IntPatchOption',
|
||||
'StringListPatchOption',
|
||||
'IntListPatchOption',
|
||||
'LongListPatchOption',
|
||||
];
|
||||
for (final Option option in options) {
|
||||
if (option.required &&
|
||||
option.value == null &&
|
||||
locator<ManagerAPI>()
|
||||
.getPatchOption(
|
||||
locator<PatcherViewModel>().selectedApp!.packageName,
|
||||
patch.name,
|
||||
option.key,
|
||||
) == null) {
|
||||
requiredOptionsType.add(option.optionClassType);
|
||||
}
|
||||
}
|
||||
for (final String optionType in requiredOptionsType) {
|
||||
if (!supportedOptionsType.contains(optionType)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
List<Option> getNullRequiredOptions(List<Patch> patches, String packageName) {
|
||||
final List<Option> requiredNullOptions = [];
|
||||
for (final patch in patches) {
|
||||
for (final patchOption in patch.options) {
|
||||
if (!patch.excluded &&
|
||||
patchOption.required &&
|
||||
patchOption.value == null &&
|
||||
locator<ManagerAPI>()
|
||||
.getPatchOption(packageName, patch.name, patchOption.key) == null) {
|
||||
requiredNullOptions.add(patchOption);
|
||||
}
|
||||
}
|
||||
}
|
||||
return requiredNullOptions;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user