import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:isolate'; import 'package:archive/archive.dart'; import 'package:fl_clash/common/archive.dart'; import 'package:fl_clash/core/core.dart'; import 'package:fl_clash/enum/enum.dart'; import 'package:fl_clash/plugins/app.dart'; import 'package:fl_clash/providers/providers.dart'; import 'package:fl_clash/state.dart'; import 'package:fl_clash/widgets/dialog.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart'; import 'package:url_launcher/url_launcher.dart'; import 'common/common.dart'; import 'models/models.dart'; class AppController { int? lastProfileModified; final BuildContext context; final WidgetRef _ref; AppController(this.context, WidgetRef ref) : _ref = ref; void setupClashConfigDebounce() { debouncer.call(FunctionTag.setupClashConfig, () async { await setupClashConfig(); }); } Future updateClashConfigDebounce() async { debouncer.call(FunctionTag.updateClashConfig, () async { await updateClashConfig(); }); } void updateGroupsDebounce() { debouncer.call(FunctionTag.updateGroups, updateGroups); } void addCheckIpNumDebounce() { debouncer.call(FunctionTag.addCheckIpNum, () { _ref.read(checkIpNumProvider.notifier).add(); }); } void applyProfileDebounce({bool silence = false}) { debouncer.call(FunctionTag.applyProfile, (silence) { applyProfile(silence: silence); }, args: [silence]); } void savePreferencesDebounce() { debouncer.call(FunctionTag.savePreferences, savePreferences); } void changeProxyDebounce(String groupName, String proxyName) { debouncer.call(FunctionTag.changeProxy, ( String groupName, String proxyName, ) async { await changeProxy(groupName: groupName, proxyName: proxyName); await updateGroups(); }, args: [groupName, proxyName]); } Future restartCore() async { await coreController.shutdown(); _ref.read(coreStatusProvider.notifier).value = CoreStatus.connecting; await coreController.preload(); await _initCore(); _ref.read(coreStatusProvider.notifier).value = CoreStatus.connected; if (_ref.read(isStartProvider)) { await globalState.handleStart(); } } Future updateStatus(bool isStart) async { if (isStart) { await globalState.handleStart([updateRunTime, updateTraffic]); final currentLastModified = await _ref .read(currentProfileProvider) ?.profileLastModified; if (currentLastModified == null || lastProfileModified == null) { addCheckIpNumDebounce(); return; } if (currentLastModified <= (lastProfileModified ?? 0)) { addCheckIpNumDebounce(); return; } applyProfileDebounce(); } else { await globalState.handleStop(); coreController.resetTraffic(); _ref.read(trafficsProvider.notifier).clear(); _ref.read(totalTrafficProvider.notifier).value = Traffic(); _ref.read(runTimeProvider.notifier).value = null; addCheckIpNumDebounce(); } } void updateRunTime() { final startTime = globalState.startTime; if (startTime != null) { final startTimeStamp = startTime.millisecondsSinceEpoch; final nowTimeStamp = DateTime.now().millisecondsSinceEpoch; _ref.read(runTimeProvider.notifier).value = nowTimeStamp - startTimeStamp; } else { _ref.read(runTimeProvider.notifier).value = null; } } Future updateTraffic() async { final onlyStatisticsProxy = _ref.read( appSettingProvider.select((state) => state.onlyStatisticsProxy), ); final traffic = await coreController.getTraffic(onlyStatisticsProxy); _ref.read(trafficsProvider.notifier).addTraffic(traffic); _ref.read(totalTrafficProvider.notifier).value = await coreController .getTotalTraffic(onlyStatisticsProxy); } Future addProfile(Profile profile) async { _ref.read(profilesProvider.notifier).setProfile(profile); if (_ref.read(currentProfileIdProvider) != null) return; _ref.read(currentProfileIdProvider.notifier).value = profile.id; } Future deleteProfile(String id) async { _ref.read(profilesProvider.notifier).deleteProfileById(id); clearEffect(id); if (globalState.config.currentProfileId == id) { final profiles = globalState.config.profiles; final currentProfileId = _ref.read(currentProfileIdProvider.notifier); if (profiles.isNotEmpty) { final updateId = profiles.first.id; currentProfileId.value = updateId; } else { currentProfileId.value = null; updateStatus(false); } } } Future updateProviders() async { _ref.read(providersProvider.notifier).value = await coreController .getExternalProviders(); } Future updateLocalIp() async { _ref.read(localIpProvider.notifier).value = null; await Future.delayed(commonDuration); _ref.read(localIpProvider.notifier).value = await utils.getLocalIpAddress(); } Future updateProfile(Profile profile) async { final newProfile = await profile.update(); _ref .read(profilesProvider.notifier) .setProfile(newProfile.copyWith(isUpdating: false)); if (profile.id == _ref.read(currentProfileIdProvider)) { applyProfileDebounce(silence: true); } } void setProfile(Profile profile) { _ref.read(profilesProvider.notifier).setProfile(profile); } void setProfileAndAutoApply(Profile profile) { _ref.read(profilesProvider.notifier).setProfile(profile); if (profile.id == _ref.read(currentProfileIdProvider)) { applyProfileDebounce(silence: true); } } void setProfiles(List profiles) { _ref.read(profilesProvider.notifier).value = profiles; } void addLog(Log log) { _ref.read(logsProvider).add(log); } void updateOrAddHotKeyAction(HotKeyAction hotKeyAction) { final hotKeyActions = _ref.read(hotKeyActionsProvider); final index = hotKeyActions.indexWhere( (item) => item.action == hotKeyAction.action, ); if (index == -1) { _ref.read(hotKeyActionsProvider.notifier).value = List.from(hotKeyActions) ..add(hotKeyAction); } else { _ref.read(hotKeyActionsProvider.notifier).value = List.from(hotKeyActions) ..[index] = hotKeyAction; } _ref.read(hotKeyActionsProvider.notifier).value = index == -1 ? (List.from(hotKeyActions)..add(hotKeyAction)) : (List.from(hotKeyActions)..[index] = hotKeyAction); } List getCurrentGroups() { return _ref.read(currentGroupsStateProvider.select((state) => state.value)); } String getRealTestUrl(String? url) { return _ref.read(getRealTestUrlProvider(url)); } int getProxiesColumns() { return _ref.read(getProxiesColumnsProvider); } dynamic addSortNum() { return _ref.read(sortNumProvider.notifier).add(); } String? getCurrentGroupName() { final currentGroupName = _ref.read( currentProfileProvider.select((state) => state?.currentGroupName), ); return currentGroupName; } ProxyCardState getProxyCardState(String proxyName) { return _ref.read(getProxyCardStateProvider(proxyName)); } String? getSelectedProxyName(String groupName) { return _ref.read(getSelectedProxyNameProvider(groupName)); } void updateCurrentGroupName(String groupName) { final profile = _ref.read(currentProfileProvider); if (profile == null || profile.currentGroupName == groupName) { return; } setProfile(profile.copyWith(currentGroupName: groupName)); } Future updateClashConfig() async { await safeRun(() async { await _updateClashConfig(); }, needLoading: true); } Future _updateClashConfig() async { final updateParams = _ref.read(updateParamsProvider); final res = await _requestAdmin(updateParams.tun.enable); if (res.isError) { return; } final realTunEnable = _ref.read(realTunEnableProvider); final message = await coreController.updateConfig( updateParams.copyWith.tun(enable: realTunEnable), ); if (message.isNotEmpty) throw message; } Future> _requestAdmin(bool enableTun) async { if (system.isWindows && kDebugMode) { return Result.success(false); } final realTunEnable = _ref.read(realTunEnableProvider); if (enableTun != realTunEnable && realTunEnable == false) { final code = await system.authorizeCore(); switch (code) { case AuthorizeCode.success: await restartCore(); return Result.error(''); case AuthorizeCode.none: break; case AuthorizeCode.error: enableTun = false; break; } } _ref.read(realTunEnableProvider.notifier).value = enableTun; return Result.success(enableTun); } Future setupClashConfig() async { await safeRun(() async { await _setupClashConfig(); }, needLoading: true); } Future _setupClashConfig() async { await _ref.read(currentProfileProvider)?.checkAndUpdate(); final patchConfig = _ref.read(patchClashConfigProvider); final res = await _requestAdmin(patchConfig.tun.enable); if (res.isError) { return; } final realTunEnable = _ref.read(realTunEnableProvider); final realPatchConfig = patchConfig.copyWith.tun(enable: realTunEnable); final params = await globalState.getSetupParams( pathConfig: realPatchConfig, ); final message = await coreController.setupConfig(params); lastProfileModified = await _ref.read( currentProfileProvider.select((state) => state?.profileLastModified), ); if (message.isNotEmpty) { throw message; } } Future _applyProfile() async { await coreController.requestGc(); await setupClashConfig(); await updateGroups(); await updateProviders(); } Future applyProfile({bool silence = false}) async { if (silence) { await _applyProfile(); } else { await safeRun(() async { await _applyProfile(); }, needLoading: true); } addCheckIpNumDebounce(); } void handleChangeProfile() { _ref.read(delayDataSourceProvider.notifier).value = {}; applyProfile(); _ref.read(logsProvider.notifier).value = FixedList(500); _ref.read(requestsProvider.notifier).value = FixedList(500); globalState.computeHeightMapCache = {}; } void updateBrightness() { WidgetsBinding.instance.addPostFrameCallback((_) { _ref.read(systemBrightnessProvider.notifier).value = WidgetsBinding.instance.platformDispatcher.platformBrightness; }); } Future autoUpdateProfiles() async { for (final profile in _ref.read(profilesProvider)) { if (!profile.autoUpdate) continue; final isNotNeedUpdate = profile.lastUpdateDate ?.add(profile.autoUpdateDuration) .isBeforeNow; if (isNotNeedUpdate == false || profile.type == ProfileType.file) { continue; } try { await updateProfile(profile); } catch (e) { commonPrint.log(e.toString()); } } } Future updateGroups() async { try { _ref.read(groupsProvider.notifier).value = await retry( task: () async { return await coreController.getProxiesGroups(); }, retryIf: (res) => res.isEmpty, ); } catch (_) { _ref.read(groupsProvider.notifier).value = []; } } Future updateProfiles() async { for (final profile in _ref.read(profilesProvider)) { if (profile.type == ProfileType.file) { continue; } await updateProfile(profile); } } Future savePreferences() async { commonPrint.log('save preferences'); await preferences.saveConfig(globalState.config); } Future changeProxy({ required String groupName, required String proxyName, }) async { await coreController.changeProxy( ChangeProxyParams(groupName: groupName, proxyName: proxyName), ); if (_ref.read(appSettingProvider).closeConnections) { coreController.closeConnections(); } addCheckIpNumDebounce(); } Future handleBackOrExit() async { if (_ref.read(backBlockProvider)) { return; } if (_ref.read(appSettingProvider).minimizeOnExit) { if (system.isDesktop) { await savePreferences(); } await system.back(); } else { await handleExit(); } } void backBlock() { _ref.read(backBlockProvider.notifier).value = true; } void unBackBlock() { _ref.read(backBlockProvider.notifier).value = false; } Future handleExit() async { Future.delayed(commonDuration, () { system.exit(); }); try { await savePreferences(); await macOS?.updateDns(true); await proxy?.stopProxy(); await coreController.shutdown(); await coreController.destroy(); } finally { system.exit(); } } Future handleClear() async { await preferences.clearPreferences(); commonPrint.log('clear preferences'); globalState.config = Config(themeProps: defaultThemeProps); } Future autoCheckUpdate() async { if (!_ref.read(appSettingProvider).autoCheckUpdate) return; final res = await request.checkForUpdate(); checkUpdateResultHandle(data: res); } Future checkUpdateResultHandle({ Map? data, bool handleError = false, }) async { if (globalState.isPre) { return; } if (data != null) { final tagName = data['tag_name']; final body = data['body']; final submits = utils.parseReleaseBody(body); final textTheme = context.textTheme; final res = await globalState.showMessage( title: appLocalizations.discoverNewVersion, message: TextSpan( text: '$tagName \n', style: textTheme.headlineSmall, children: [ TextSpan(text: '\n', style: textTheme.bodyMedium), for (final submit in submits) TextSpan(text: '- $submit \n', style: textTheme.bodyMedium), ], ), confirmText: appLocalizations.goDownload, ); if (res != true) { return; } launchUrl(Uri.parse('https://github.com/$repository/releases/latest')); } else if (handleError) { globalState.showMessage( title: appLocalizations.checkUpdate, message: TextSpan(text: appLocalizations.checkUpdateError), ); } } Future _handlePreference() async { if (await preferences.isInit) { return; } final res = await globalState.showMessage( title: appLocalizations.tip, message: TextSpan(text: appLocalizations.cacheCorrupt), ); if (res == true) { final file = File(await appPath.sharedPreferencesPath); final isExists = await file.exists(); if (isExists) { await file.delete(); } } await handleExit(); } Future _initCore() async { final isInit = await coreController.isInit; if (!isInit) { await coreController.init(globalState.appState.version); } await applyProfile(); } Future init() async { FlutterError.onError = (details) { if (kDebugMode) { commonPrint.log( 'exception: ${details.exception} stack: ${details.stack}', ); } }; updateTray(true); await _initCore(); await _initStatus(); autoLaunch?.updateStatus(_ref.read(appSettingProvider).autoLaunch); autoUpdateProfiles(); autoCheckUpdate(); if (!_ref.read(appSettingProvider).silentLaunch) { window?.show(); } else { window?.hide(); } await _handlePreference(); await _handlerDisclaimer(); _ref.read(initProvider.notifier).value = true; } Future _initStatus() async { if (system.isAndroid) { await globalState.updateStartTime(); } final status = globalState.isStart == true ? true : _ref.read(appSettingProvider).autoRun; await updateStatus(status); if (!status) { addCheckIpNumDebounce(); } } void setDelay(Delay delay) { _ref.read(delayDataSourceProvider.notifier).setDelay(delay); } void toPage(PageLabel pageLabel) { _ref.read(currentPageLabelProvider.notifier).value = pageLabel; } void toProfiles() { toPage(PageLabel.profiles); } void initLink() { linkManager.initAppLinksListen((url) async { final res = await globalState.showMessage( title: '${appLocalizations.add}${appLocalizations.profile}', message: TextSpan( children: [ TextSpan(text: appLocalizations.doYouWantToPass), TextSpan( text: ' $url ', style: TextStyle( color: Theme.of(context).colorScheme.primary, decoration: TextDecoration.underline, decorationColor: Theme.of(context).colorScheme.primary, ), ), TextSpan( text: '${appLocalizations.create}${appLocalizations.profile}', ), ], ), ); if (res != true) { return; } addProfileFormURL(url); }); } Future showDisclaimer() async { return await globalState.showCommonDialog( dismissible: false, child: CommonDialog( title: appLocalizations.disclaimer, actions: [ TextButton( onPressed: () { Navigator.of(context).pop(false); }, child: Text(appLocalizations.exit), ), TextButton( onPressed: () { _ref .read(appSettingProvider.notifier) .updateState( (state) => state.copyWith(disclaimerAccepted: true), ); Navigator.of(context).pop(true); }, child: Text(appLocalizations.agree), ), ], child: SelectableText(appLocalizations.disclaimerDesc), ), ) ?? false; } Future _handlerDisclaimer() async { if (_ref.read(appSettingProvider).disclaimerAccepted) { return; } final isDisclaimerAccepted = await showDisclaimer(); if (!isDisclaimerAccepted) { await handleExit(); } return; } Future addProfileFormURL(String url) async { if (globalState.navigatorKey.currentState?.canPop() ?? false) { globalState.navigatorKey.currentState?.popUntil((route) => route.isFirst); } toProfiles(); final profile = await safeRun( () async { return await Profile.normal(url: url).update(); }, needLoading: true, title: '${appLocalizations.add}${appLocalizations.profile}', ); if (profile != null) { await addProfile(profile); } } Future addProfileFormFile() async { final platformFile = await safeRun(picker.pickerFile); final bytes = platformFile?.bytes; if (bytes == null) { return; } if (!context.mounted) return; globalState.navigatorKey.currentState?.popUntil((route) => route.isFirst); toProfiles(); final profile = await safeRun( () async { await Future.delayed(const Duration(milliseconds: 300)); return await Profile.normal(label: platformFile?.name).saveFile(bytes); }, needLoading: true, title: '${appLocalizations.add}${appLocalizations.profile}', ); if (profile != null) { await addProfile(profile); } } Future addProfileFormQrCode() async { final url = await safeRun(picker.pickerConfigQRCode); if (url == null) return; addProfileFormURL(url); } void updateViewSize(Size size) { WidgetsBinding.instance.addPostFrameCallback((_) { _ref.read(viewSizeProvider.notifier).value = size; }); } void setProvider(ExternalProvider? provider) { _ref.read(providersProvider.notifier).setProvider(provider); } List _sortOfName(List proxies) { return List.of(proxies)..sort( (a, b) => utils.sortByChar(utils.getPinyin(a.name), utils.getPinyin(b.name)), ); } List _sortOfDelay({required List proxies, String? testUrl}) { return List.of(proxies)..sort((a, b) { final aDelay = _ref.read( getDelayProvider(proxyName: a.name, testUrl: testUrl), ); final bDelay = _ref.read( getDelayProvider(proxyName: b.name, testUrl: testUrl), ); if (aDelay == null && bDelay == null) { return 0; } if (aDelay == null || aDelay == -1) { return 1; } if (bDelay == null || bDelay == -1) { return -1; } return aDelay.compareTo(bDelay); }); } List getSortProxies({ required List proxies, required ProxiesSortType sortType, String? testUrl, }) { return switch (sortType) { ProxiesSortType.none => proxies, ProxiesSortType.delay => _sortOfDelay(proxies: proxies, testUrl: testUrl), ProxiesSortType.name => _sortOfName(proxies), }; } Future clearEffect(String profileId) async { final profilePath = await appPath.getProfilePath(profileId); final providersDirPath = await appPath.getProvidersDirPath(profileId); return await Isolate.run(() async { final profileFile = File(profilePath); final isExists = await profileFile.exists(); if (isExists) { profileFile.delete(recursive: true); } final providersFileDir = File(providersDirPath); final providersFileIsExists = await providersFileDir.exists(); if (providersFileIsExists) { providersFileDir.delete(recursive: true); } }); } void updateTun() { _ref .read(patchClashConfigProvider.notifier) .updateState((state) => state.copyWith.tun(enable: !state.tun.enable)); } void updateSystemProxy() { _ref .read(networkSettingProvider.notifier) .updateState( (state) => state.copyWith(systemProxy: !state.systemProxy), ); } void handleCoreDisconnected() { _ref.read(coreStatusProvider.notifier).value = CoreStatus.disconnected; } Future> getPackages() async { if (_ref.read(isMobileViewProvider)) { await Future.delayed(commonDuration); } if (_ref.read(packagesProvider).isEmpty) { _ref.read(packagesProvider.notifier).value = await app?.getPackages() ?? []; } return _ref.read(packagesProvider); } void updateStart() { updateStatus(!_ref.read(isStartProvider)); } void updateCurrentSelectedMap(String groupName, String proxyName) { final currentProfile = _ref.read(currentProfileProvider); if (currentProfile != null && currentProfile.selectedMap[groupName] != proxyName) { final SelectedMap selectedMap = Map.from(currentProfile.selectedMap) ..[groupName] = proxyName; _ref .read(profilesProvider.notifier) .setProfile(currentProfile.copyWith(selectedMap: selectedMap)); } } void updateCurrentUnfoldSet(Set value) { final currentProfile = _ref.read(currentProfileProvider); if (currentProfile == null) { return; } _ref .read(profilesProvider.notifier) .setProfile(currentProfile.copyWith(unfoldSet: value)); } void changeMode(Mode mode) { _ref .read(patchClashConfigProvider.notifier) .updateState((state) => state.copyWith(mode: mode)); if (mode == Mode.global) { updateCurrentGroupName(GroupName.GLOBAL.name); } addCheckIpNumDebounce(); } void updateAutoLaunch() { _ref .read(appSettingProvider.notifier) .updateState((state) => state.copyWith(autoLaunch: !state.autoLaunch)); } Future updateVisible() async { final visible = await window?.isVisible; if (visible != null && !visible) { window?.show(); } else { window?.hide(); } } void updateMode() { _ref.read(patchClashConfigProvider.notifier).updateState((state) { final index = Mode.values.indexWhere((item) => item == state.mode); if (index == -1) { return null; } final nextIndex = index + 1 > Mode.values.length - 1 ? 0 : index + 1; return state.copyWith(mode: Mode.values[nextIndex]); }); } Future exportLogs() async { final logsRaw = _ref.read(logsProvider).list.map((item) => item.toString()); final data = await Isolate.run>(() async { final logsRawString = logsRaw.join('\n'); return utf8.encode(logsRawString); }); return await picker.saveFile(utils.logFile, Uint8List.fromList(data)) != null; } Future> backupData() async { final homeDirPath = await appPath.homeDirPath; final profilesPath = await appPath.profilesPath; final configJson = globalState.config.toJson(); return Isolate.run>(() async { final archive = Archive(); archive.addTextFile('config.json', configJson); archive.addDirectoryToArchive(profilesPath, homeDirPath); final zipEncoder = ZipEncoder(); return zipEncoder.encode(archive); }); } Future updateTray([bool focus = false]) async { tray?.update(trayState: _ref.read(trayStateProvider)); } Future recoveryData( List data, RecoveryOption recoveryOption, ) async { final archive = await Isolate.run(() { final zipDecoder = ZipDecoder(); return zipDecoder.decodeBytes(data); }); final homeDirPath = await appPath.homeDirPath; final configs = archive.files .where((item) => item.name.endsWith('.json')) .toList(); final profiles = archive.files.where( (item) => !item.name.endsWith('.json'), ); final configIndex = configs.indexWhere( (config) => config.name == 'config.json', ); if (configIndex == -1) throw 'invalid backup file'; final configFile = configs[configIndex]; var tempConfig = Config.compatibleFromJson( json.decode(utf8.decode(configFile.content)), ); for (final profile in profiles) { final filePath = join(homeDirPath, profile.name); final file = File(filePath); await file.create(recursive: true); await file.writeAsBytes(profile.content); } final clashConfigIndex = configs.indexWhere( (config) => config.name == 'clashConfig.json', ); if (clashConfigIndex != -1) { final clashConfigFile = configs[clashConfigIndex]; tempConfig = tempConfig.copyWith( patchClashConfig: ClashConfig.fromJson( json.decode(utf8.decode(clashConfigFile.content)), ), ); } _recovery(tempConfig, recoveryOption); } void _recovery(Config config, RecoveryOption recoveryOption) { final recoveryStrategy = _ref.read( appSettingProvider.select((state) => state.recoveryStrategy), ); final profiles = config.profiles; if (recoveryStrategy == RecoveryStrategy.override) { _ref.read(profilesProvider.notifier).value = profiles; } else { for (final profile in profiles) { _ref.read(profilesProvider.notifier).setProfile(profile); } } final onlyProfiles = recoveryOption == RecoveryOption.onlyProfiles; if (!onlyProfiles) { _ref.read(patchClashConfigProvider.notifier).value = config.patchClashConfig; _ref.read(appSettingProvider.notifier).value = config.appSetting; _ref.read(currentProfileIdProvider.notifier).value = config.currentProfileId; _ref.read(appDAVSettingProvider.notifier).value = config.dav; _ref.read(themeSettingProvider.notifier).value = config.themeProps; _ref.read(windowSettingProvider.notifier).value = config.windowProps; _ref.read(vpnSettingProvider.notifier).value = config.vpnProps; _ref.read(proxiesStyleSettingProvider.notifier).value = config.proxiesStyle; _ref.read(overrideDnsProvider.notifier).value = config.overrideDns; _ref.read(networkSettingProvider.notifier).value = config.networkProps; _ref.read(hotKeyActionsProvider.notifier).value = config.hotKeyActions; _ref.read(scriptStateProvider.notifier).value = config.scriptProps; } final currentProfile = _ref.read(currentProfileProvider); if (currentProfile == null) { _ref.read(currentProfileIdProvider.notifier).value = profiles.first.id; } } Future safeRun( FutureOr Function() futureFunction, { String? title, bool needLoading = false, bool silence = true, }) async { final realSilence = needLoading == true ? true : silence; try { if (needLoading) { _ref.read(loadingProvider.notifier).value = true; } final res = await futureFunction(); return res; } catch (e) { commonPrint.log('$futureFunction ===> $e'); if (realSilence) { globalState.showNotifier(e.toString()); } else { globalState.showMessage( title: title ?? appLocalizations.tip, message: TextSpan(text: e.toString()), ); } return null; } finally { _ref.read(loadingProvider.notifier).value = false; } } }