Release 9.2.0

This commit is contained in:
Alessandro Autiero
2024-07-06 18:43:52 +02:00
parent 45b8629207
commit e3b8d7d182
91 changed files with 3871 additions and 3132 deletions

View File

@@ -0,0 +1,23 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:reboot_launcher/src/messenger/abstract/dialog.dart';
import 'package:reboot_launcher/src/util/translations.dart';
Future<void> showResetDialog(Function() onConfirm) => showRebootDialog(
builder: (context) => InfoDialog(
text: translations.resetDefaultsDialogTitle,
buttons: [
DialogButton(
type: ButtonType.secondary,
text: translations.resetDefaultsDialogSecondaryAction,
),
DialogButton(
type: ButtonType.primary,
text: translations.resetDefaultsDialogPrimaryAction,
onTap: () {
onConfirm();
Navigator.of(context).pop();
},
)
],
)
);

View File

@@ -0,0 +1,23 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:reboot_launcher/src/messenger/abstract/dialog.dart';
import 'package:reboot_launcher/src/util/translations.dart';
Future<void> showDllDeletedDialog(Function() onConfirm) => showRebootDialog(
builder: (context) => InfoDialog(
text: translations.dllDeletedTitle,
buttons: [
DialogButton(
type: ButtonType.secondary,
text: translations.dllDeletedSecondaryAction,
),
DialogButton(
type: ButtonType.secondary,
text: translations.dllDeletedPrimaryAction,
onTap: () {
Navigator.pop(context);
onConfirm();
},
),
],
)
);

View File

@@ -0,0 +1,34 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/src/messenger/abstract/dialog.dart';
import 'package:reboot_launcher/src/page/pages.dart';
import 'package:reboot_launcher/src/util/translations.dart';
String? lastError;
void onError(Object exception, StackTrace? stackTrace, bool framework) {
log("[ERROR] $exception");
log("[STACKTRACE] $stackTrace");
if(pageKey.currentContext == null || pageKey.currentState?.mounted == false){
return;
}
if(lastError == exception.toString()){
return;
}
lastError = exception.toString();
final route = ModalRoute.of(pageKey.currentContext!);
if(route != null && !route.isCurrent){
Navigator.of(pageKey.currentContext!).pop(false);
}
WidgetsBinding.instance.addPostFrameCallback((timeStamp) => showRebootDialog(
builder: (context) =>
ErrorDialog(
exception: exception,
stackTrace: stackTrace,
errorMessageBuilder: (exception) => translations.uncaughtErrorMessage(exception.toString())
)
));
}

View File

@@ -0,0 +1,346 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:get/get.dart';
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/src/controller/backend_controller.dart';
import 'package:reboot_launcher/src/controller/game_controller.dart';
import 'package:reboot_launcher/src/controller/hosting_controller.dart';
import 'package:reboot_launcher/src/controller/settings_controller.dart';
import 'package:reboot_launcher/src/messenger/abstract/overlay.dart';
import 'package:reboot_launcher/src/messenger/implementation/profile.dart';
import 'package:reboot_launcher/src/page/abstract/page_type.dart';
import 'package:reboot_launcher/src/page/implementation/backend_page.dart';
import 'package:reboot_launcher/src/page/implementation/home_page.dart';
import 'package:reboot_launcher/src/page/implementation/host_page.dart';
import 'package:reboot_launcher/src/page/implementation/play_page.dart';
import 'package:reboot_launcher/src/page/pages.dart';
import 'package:reboot_launcher/src/util/translations.dart';
import 'package:reboot_launcher/src/widget/version_selector.dart';
void startOnboarding() {
final settingsController = Get.find<SettingsController>();
settingsController.firstRun.value = false;
profileOverlayKey.currentState!.showOverlay(
text: translations.startOnboardingText,
offset: Offset(27.5, 17.5),
actionBuilder: (context, onClose) => _buildActionButton(
context: context,
label: translations.startOnboardingActionLabel,
onTap: () async {
onClose();
await showProfileForm(context);
_promptPlayPage();
}
)
);
}
void _promptPlayPage() {
pageIndex.value = RebootPageType.play.index;
pageOverlayTargetKey.currentState!.showOverlay(
text: translations.promptPlayPageText,
actionBuilder: (context, onClose) => _buildActionButton(
context: context,
label: translations.promptPlayPageActionLabel,
onTap: () async {
onClose();
_promptPlayVersion();
}
)
);
}
void _promptPlayVersion() {
final gameController = Get.find<GameController>();
final hasBuilds = gameController.versions.value.isNotEmpty;
gameVersionOverlayTargetKey.currentState!.showOverlay(
text: translations.promptPlayVersionText,
attachMode: AttachMode.middle,
offset: Offset(-25, 0),
actionBuilder: (context, onClose) => _buildActionButton(
context: context,
label: hasBuilds ? translations.promptPlayVersionActionLabelHasBuilds : translations.promptPlayVersionActionLabelNoBuilds,
onTap: () async {
onClose();
if(!hasBuilds) {
await VersionSelector.openDownloadDialog(closable: false);
}
_promptServerBrowserPage();
}
)
);
}
void _promptServerBrowserPage() {
pageIndex.value = RebootPageType.browser.index;
pageOverlayTargetKey.currentState!.showOverlay(
text: translations.promptServerBrowserPageText,
actionBuilder: (context, onClose) => _buildActionButton(
context: context,
label: translations.promptServerBrowserPageActionLabel,
onTap: () {
onClose();
_promptHostPage();
}
)
);
}
void _promptHostPage() {
pageIndex.value = RebootPageType.host.index;
pageOverlayTargetKey.currentState!.showOverlay(
text: translations.promptHostPageText,
actionBuilder: (context, onClose) => _buildActionButton(
context: context,
label: translations.promptHostPageActionLabel,
onTap: () {
onClose();
_promptHostInfo();
}
)
);
}
void _promptHostInfo() {
final hostingController = Get.find<HostingController>();
hostInfoOverlayTargetKey.currentState!.showOverlay(
text: translations.promptHostInfoText,
offset: Offset(-10, 2.5),
actionBuilder: (context, onClose) => Row(
children: [
_buildActionButton(
context: context,
label: translations.promptHostInfoActionLabelSkip,
themed: false,
onTap: () {
onClose();
hostingController.discoverable.value = false;
_promptHostVersion();
}
),
const SizedBox(width: 12.0),
_buildActionButton(
context: context,
label: translations.promptHostInfoActionLabelConfigure,
onTap: () {
onClose();
hostingController.discoverable.value = true;
hostInfoTileKey.currentState!.openNestedPage();
WidgetsBinding.instance.addPostFrameCallback((_) => _promptHostInformation());
}
)
],
)
);
}
void _promptHostInformation() {
final hostingController = Get.find<HostingController>();
hostingController.nameFocusNode.requestFocus();
hostInfoNameOverlayTargetKey.currentState!.showOverlay(
text: translations.promptHostInformationText,
attachMode: AttachMode.middle,
ignoreTargetPointers: false,
offset: Offset(100, 0),
actionBuilder: (context, onClose) => _buildActionButton(
context: context,
label: translations.promptHostInformationActionLabel,
onTap: () {
onClose();
_promptHostInformationDescription();
}
)
);
}
void _promptHostInformationDescription() {
final hostingController = Get.find<HostingController>();
hostingController.descriptionFocusNode.requestFocus();
hostInfoDescriptionOverlayTargetKey.currentState!.showOverlay(
text: translations.promptHostInformationDescriptionText,
attachMode: AttachMode.middle,
ignoreTargetPointers: false,
offset: Offset(70, 0),
actionBuilder: (context, onClose) => _buildActionButton(
context: context,
label: translations.promptHostInformationDescriptionActionLabel,
onTap: () {
onClose();
_promptHostInformationPassword();
}
)
);
}
void _promptHostInformationPassword() {
final hostingController = Get.find<HostingController>();
hostingController.passwordFocusNode.requestFocus();
hostInfoPasswordOverlayTargetKey.currentState!.showOverlay(
text: translations.promptHostInformationPasswordText,
ignoreTargetPointers: false,
attachMode: AttachMode.middle,
offset: Offset(25, 0),
actionBuilder: (context, onClose) => _buildActionButton(
context: context,
label: translations.promptHostInformationPasswordActionLabel,
onTap: () {
onClose();
Navigator.of(hostInfoTileKey.currentContext!).pop();
pageStack.removeLast();
WidgetsBinding.instance.addPostFrameCallback((_) => _promptHostVersion());
}
)
);
}
void _promptHostVersion() {
hostVersionOverlayTargetKey.currentState!.showOverlay(
text: translations.promptHostVersionText,
attachMode: AttachMode.end,
actionBuilder: (context, onClose) => _buildActionButton(
context: context,
label: translations.promptHostVersionActionLabel,
onTap: () {
onClose();
_promptHostShare();
}
)
);
}
void _promptHostShare() {
final backendController = Get.find<BackendController>();
hostShareOverlayTargetKey.currentState!.showOverlay(
text: translations.promptHostShareText,
offset: Offset(-10, 2.5),
actionBuilder: (context, onClose) => _buildActionButton(
context: context,
label: translations.promptHostShareActionLabel,
onTap: () {
onClose();
backendController.type.value = ServerType.embedded;
_promptBackendPage();
}
)
);
}
void _promptBackendPage() {
pageIndex.value = RebootPageType.backend.index;
pageOverlayTargetKey.currentState!.showOverlay(
text: translations.promptBackendPageText,
actionBuilder: (context, onClose) => _buildActionButton(
context: context,
label: translations.promptBackendPageActionLabel,
onTap: () {
onClose();
_promptBackendTypePage();
}
)
);
}
void _promptBackendTypePage() {
backendTypeOverlayTargetKey.currentState!.showOverlay(
text: translations.promptBackendTypePageText,
attachMode: AttachMode.end,
offset: Offset(-25, 0),
actionBuilder: (context, onClose) => _buildActionButton(
context: context,
label: translations.promptBackendTypePageActionLabel,
onTap: () {
onClose();
_promptBackendGameServerAddress();
}
)
);
}
void _promptBackendGameServerAddress() {
backendGameServerAddressOverlayTargetKey.currentState!.showOverlay(
text: translations.promptBackendGameServerAddressText,
attachMode: AttachMode.end,
offset: Offset(-100, 0),
actionBuilder: (context, onClose) => _buildActionButton(
context: context,
label: translations.promptBackendGameServerAddressActionLabel,
onTap: () {
onClose();
_promptBackendUnrealEngineKey();
}
)
);
}
void _promptBackendUnrealEngineKey() {
backendUnrealEngineOverlayTargetKey.currentState!.showOverlay(
text: translations.promptBackendUnrealEngineKeyText,
attachMode: AttachMode.end,
offset: Offset(-465, 2.5),
actionBuilder: (context, onClose) => _buildActionButton(
context: context,
label: translations.promptBackendUnrealEngineKeyActionLabel,
onTap: () {
onClose();
_promptBackendDetached();
}
)
);
}
void _promptBackendDetached() {
backendDetachedOverlayTargetKey.currentState!.showOverlay(
text: translations.promptBackendDetachedText,
attachMode: AttachMode.end,
offset: Offset(-410, 2.5),
actionBuilder: (context, onClose) => _buildActionButton(
context: context,
label: translations.promptBackendDetachedActionLabel,
onTap: () {
onClose();
_promptInfoTab();
}
)
);
}
void _promptInfoTab() {
pageIndex.value = RebootPageType.info.index;
pageOverlayTargetKey.currentState!.showOverlay(
text: translations.promptInfoTabText,
actionBuilder: (context, onClose) => _buildActionButton(
context: context,
label: translations.promptInfoTabActionLabel,
onTap: () {
onClose();
_promptSettingsTab();
}
)
);
}
void _promptSettingsTab() {
pageIndex.value = RebootPageType.settings.index;
pageOverlayTargetKey.currentState!.showOverlay(
text: translations.promptSettingsTabText,
actionBuilder: (context, onClose) => _buildActionButton(
context: context,
label: translations.promptSettingsTabActionLabel,
onTap: onClose
)
);
}
Widget _buildActionButton({
required BuildContext context,
required String label,
bool themed = true,
required void Function() onTap,
}) => Button(
style: themed ? ButtonStyle(
backgroundColor: ButtonState.all(FluentTheme.of(context).accentColor)
) : null,
child: Text(label),
onPressed: onTap
);

View File

@@ -0,0 +1,81 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:flutter/material.dart' show Icons;
import 'package:get/get.dart';
import 'package:reboot_launcher/src/controller/game_controller.dart';
import 'package:reboot_launcher/src/messenger/abstract/dialog.dart';
import 'package:reboot_launcher/src/util/translations.dart';
final GameController _gameController = Get.find<GameController>();
Future<bool> showProfileForm(BuildContext context) async{
final showPassword = RxBool(false);
final oldUsername = _gameController.username.text;
final showPasswordTrailing = RxBool(oldUsername.isNotEmpty);
final oldPassword = _gameController.password.text;
final result = await showRebootDialog<bool?>(
builder: (context) => Obx(() => FormDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InfoLabel(
label: translations.usernameOrEmail,
child: TextFormBox(
placeholder: translations.usernameOrEmailPlaceholder,
controller: _gameController.username,
autovalidateMode: AutovalidateMode.always,
enableSuggestions: true,
autofocus: true,
autocorrect: false,
)
),
const SizedBox(height: 16.0),
InfoLabel(
label: translations.password,
child: TextFormBox(
placeholder: translations.passwordPlaceholder,
controller: _gameController.password,
autovalidateMode: AutovalidateMode.always,
obscureText: !showPassword.value,
enableSuggestions: false,
autocorrect: false,
onChanged: (text) => showPasswordTrailing.value = text.isNotEmpty,
suffix: Button(
onPressed: () => showPassword.value = !showPassword.value,
style: ButtonStyle(
shape: ButtonState.all(const CircleBorder()),
backgroundColor: ButtonState.all(Colors.transparent)
),
child: Icon(
showPassword.value ? Icons.visibility_off : Icons.visibility,
color: showPasswordTrailing.value ? null : Colors.transparent
),
)
)
),
const SizedBox(height: 8.0)
],
),
buttons: [
DialogButton(
text: translations.cancelProfileChanges,
type: ButtonType.secondary
),
DialogButton(
text: translations.saveProfileChanges,
type: ButtonType.primary,
onTap: () => Navigator.of(context).pop(true)
)
]
))
) ?? false;
if(result) {
return true;
}
_gameController.username.text = oldUsername;
_gameController.password.text = oldPassword;
return false;
}

View File

@@ -0,0 +1,277 @@
import 'dart:async';
import 'package:clipboard/clipboard.dart';
import 'package:fluent_ui/fluent_ui.dart' hide FluentIcons;
import 'package:fluentui_system_icons/fluentui_system_icons.dart';
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/src/controller/backend_controller.dart';
import 'package:reboot_launcher/src/controller/game_controller.dart';
import 'package:reboot_launcher/src/messenger/abstract/dialog.dart';
import 'package:reboot_launcher/src/messenger/abstract/info_bar.dart';
import 'package:reboot_launcher/src/page/abstract/page_type.dart';
import 'package:reboot_launcher/src/page/pages.dart';
import 'package:reboot_launcher/src/util/cryptography.dart';
import 'package:reboot_launcher/src/util/matchmaker.dart';
import 'package:reboot_launcher/src/util/translations.dart';
extension ServerControllerDialog on BackendController {
Future<bool> toggleInteractive() async {
final stream = toggle();
final completer = Completer<bool>();
InfoBarEntry? entry;
worker = stream.listen((event) {
entry?.close();
entry = _handeEvent(event);
if(event.type.isError) {
completer.complete(false);
}else if(event.type.isSuccess) {
completer.complete(true);
}
});
return await completer.future;
}
InfoBarEntry _handeEvent(ServerResult event) {
log("[BACKEND] Handling event: $event");
switch (event.type) {
case ServerResultType.starting:
return showRebootInfoBar(
translations.startingServer,
severity: InfoBarSeverity.info,
loading: true,
duration: null
);
case ServerResultType.startSuccess:
return showRebootInfoBar(
type.value == ServerType.local ? translations.checkedServer : translations.startedServer,
severity: InfoBarSeverity.success
);
case ServerResultType.startError:
print(event.stackTrace);
return showRebootInfoBar(
type.value == ServerType.local ? translations.localServerError(event.error ?? translations.unknownError) : translations.startServerError(event.error ?? translations.unknownError),
severity: InfoBarSeverity.error,
duration: infoBarLongDuration
);
case ServerResultType.stopping:
return showRebootInfoBar(
translations.stoppingServer,
severity: InfoBarSeverity.info,
loading: true,
duration: null
);
case ServerResultType.stopSuccess:
return showRebootInfoBar(
translations.stoppedServer,
severity: InfoBarSeverity.success
);
case ServerResultType.stopError:
return showRebootInfoBar(
translations.stopServerError(event.error ?? translations.unknownError),
severity: InfoBarSeverity.error,
duration: infoBarLongDuration
);
case ServerResultType.missingHostError:
return showRebootInfoBar(
translations.missingHostNameError,
severity: InfoBarSeverity.error
);
case ServerResultType.missingPortError:
return showRebootInfoBar(
translations.missingPortError,
severity: InfoBarSeverity.error
);
case ServerResultType.illegalPortError:
return showRebootInfoBar(
translations.illegalPortError,
severity: InfoBarSeverity.error
);
case ServerResultType.freeingPort:
return showRebootInfoBar(
translations.freeingPort,
loading: true,
duration: null
);
case ServerResultType.freePortSuccess:
return showRebootInfoBar(
translations.freedPort,
severity: InfoBarSeverity.success,
duration: infoBarShortDuration
);
case ServerResultType.freePortError:
return showRebootInfoBar(
translations.freePortError(event.error ?? translations.unknownError),
severity: InfoBarSeverity.error,
duration: infoBarLongDuration
);
case ServerResultType.pingingRemote:
return showRebootInfoBar(
translations.pingingServer(ServerType.remote.name),
severity: InfoBarSeverity.info,
loading: true,
duration: null
);
case ServerResultType.pingingLocal:
return showRebootInfoBar(
translations.pingingServer(type.value.name),
severity: InfoBarSeverity.info,
loading: true,
duration: null
);
case ServerResultType.pingError:
return showRebootInfoBar(
translations.pingError(type.value.name),
severity: InfoBarSeverity.error
);
case ServerResultType.processError:
return showRebootInfoBar(
translations.backendProcessError,
severity: InfoBarSeverity.error
);
}
}
Future<void> joinServerInteractive(String uuid, FortniteServer server) async {
if(!kDebugMode && uuid == server.id) {
showRebootInfoBar(
translations.joinSelfServer,
duration: infoBarLongDuration,
severity: InfoBarSeverity.error
);
return;
}
final gameController = Get.find<GameController>();
final version = gameController.getVersionByName(server.version.toString());
if(version == null) {
showRebootInfoBar(
translations.cannotJoinServerVersion(server.version.toString()),
duration: infoBarLongDuration,
severity: InfoBarSeverity.error
);
return;
}
final hashedPassword = server.password;
final hasPassword = hashedPassword != null;
final embedded = type.value == ServerType.embedded;
final author = server.author;
final encryptedIp = server.ip;
if(!hasPassword) {
final valid = await _isServerValid(encryptedIp);
if(!valid) {
return;
}
_onSuccess(gameController, embedded, encryptedIp, author, version);
return;
}
final confirmPassword = await _askForPassword();
if(confirmPassword == null) {
return;
}
if(!checkPassword(confirmPassword, hashedPassword)) {
showRebootInfoBar(
translations.wrongServerPassword,
duration: infoBarLongDuration,
severity: InfoBarSeverity.error
);
return;
}
final decryptedIp = aes256Decrypt(encryptedIp, confirmPassword);
final valid = await _isServerValid(decryptedIp);
if(!valid) {
return;
}
_onSuccess(gameController, embedded, decryptedIp, author, version);
}
Future<bool> _isServerValid(String address) async {
final result = await pingGameServer(address);
if(result) {
return true;
}
showRebootInfoBar(
translations.offlineServer,
duration: infoBarLongDuration,
severity: InfoBarSeverity.error
);
return false;
}
Future<String?> _askForPassword() async {
final confirmPasswordController = TextEditingController();
final showPassword = RxBool(false);
final showPasswordTrailing = RxBool(false);
return await showRebootDialog<String?>(
builder: (context) => FormDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InfoLabel(
label: translations.serverPassword,
child: Obx(() => TextFormBox(
placeholder: translations.serverPasswordPlaceholder,
controller: confirmPasswordController,
autovalidateMode: AutovalidateMode.always,
obscureText: !showPassword.value,
enableSuggestions: false,
autofocus: true,
autocorrect: false,
onChanged: (text) => showPasswordTrailing.value = text.isNotEmpty,
suffix: !showPasswordTrailing.value ? null : Button(
onPressed: () => showPassword.value = !showPassword.value,
style: ButtonStyle(
shape: ButtonState.all(const CircleBorder()),
backgroundColor: ButtonState.all(Colors.transparent)
),
child: Icon(
showPassword.value ? FluentIcons.eye_off_24_regular : FluentIcons.eye_24_regular
),
)
))
),
const SizedBox(height: 8.0)
],
),
buttons: [
DialogButton(
text: translations.serverPasswordCancel,
type: ButtonType.secondary
),
DialogButton(
text: translations.serverPasswordConfirm,
type: ButtonType.primary,
onTap: () => Navigator.of(context).pop(confirmPasswordController.text)
)
]
)
);
}
void _onSuccess(GameController controller, bool embedded, String decryptedIp, String author, FortniteVersion version) {
if(embedded) {
gameServerAddress.text = decryptedIp;
pageIndex.value = RebootPageType.play.index;
}else {
FlutterClipboard.controlC(decryptedIp);
}
controller.selectedVersion = version;
WidgetsBinding.instance.addPostFrameCallback((_) => showRebootInfoBar(
embedded ? translations.joinedServer(author) : translations.copiedIp,
duration: infoBarLongDuration,
severity: InfoBarSeverity.success
));
}
}

View File

@@ -0,0 +1,463 @@
import 'dart:async';
import 'dart:io';
import 'dart:isolate';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/src/controller/game_controller.dart';
import 'package:reboot_launcher/src/messenger/abstract/dialog.dart';
import 'package:reboot_launcher/src/util/translations.dart';
import 'package:reboot_launcher/src/widget/file_selector.dart';
import 'package:universal_disk_space/universal_disk_space.dart';
import 'package:windows_taskbar/windows_taskbar.dart';
class AddVersionDialog extends StatefulWidget {
final bool closable;
const AddVersionDialog({Key? key, required this.closable}) : super(key: key);
@override
State<AddVersionDialog> createState() => _AddVersionDialogState();
}
class _AddVersionDialogState extends State<AddVersionDialog> {
final GameController _gameController = Get.find<GameController>();
final TextEditingController _pathController = TextEditingController();
final GlobalKey<FormState> _formKey = GlobalKey();
final GlobalKey<FormFieldState> _formFieldKey = GlobalKey();
final Rx<_DownloadStatus> _status = Rx(_DownloadStatus.form);
final Rx<_BuildSource> _source = Rx(_BuildSource.githubArchive);
final Rxn<FortniteBuild> _build = Rxn();
final RxnInt _timeLeft = RxnInt();
final Rxn<double> _progress = Rxn();
late DiskSpace _diskSpace;
late Future<List<FortniteBuild>> _fetchFuture;
late Future _diskFuture;
Isolate? _isolate;
SendPort? _downloadPort;
Object? _error;
StackTrace? _stackTrace;
@override
void initState() {
_fetchFuture = compute(fetchBuilds, null);
_diskSpace = DiskSpace();
_diskFuture = _diskSpace.scan()
.then((_) => _updateFormDefaults());
super.initState();
}
@override
void dispose() {
_pathController.dispose();
_cancelDownload();
super.dispose();
}
void _cancelDownload() {
Process.run('${assetsDirectory.path}\\build\\stop.bat', []);
_downloadPort?.send(kStopBuildDownloadSignal);
_isolate?.kill(priority: Isolate.immediate);
}
@override
Widget build(BuildContext context) => Form(
key: _formKey,
child: Obx(() {
switch(_status.value){
case _DownloadStatus.form:
return FutureBuilder(
future: Future.wait([_fetchFuture, _diskFuture]).then((_) async => await _fetchFuture),
builder: (context, snapshot) {
if (snapshot.hasError) {
WidgetsBinding.instance.addPostFrameCallback((_) => _onDownloadError(snapshot.error, snapshot.stackTrace));
}
final data = snapshot.data;
if (data == null) {
return ProgressDialog(
text: translations.fetchingBuilds,
showButton: widget.closable,
onStop: () => Navigator.of(context).pop()
);
}
return Obx(() => FormDialog(
content: _buildFormBody(data),
buttons: _formButtons
));
}
);
case _DownloadStatus.downloading:
case _DownloadStatus.extracting:
return GenericDialog(
header: _progressBody,
buttons: _stopButton
);
case _DownloadStatus.error:
return ErrorDialog(
exception: _error ?? Exception(translations.unknownError),
stackTrace: _stackTrace,
errorMessageBuilder: (exception) => translations.downloadVersionError(exception.toString())
);
case _DownloadStatus.done:
return InfoDialog(
text: translations.downloadedVersion
);
}
})
);
List<DialogButton> get _formButtons => [
if(widget.closable)
DialogButton(type: ButtonType.secondary),
DialogButton(
text: _source.value == _BuildSource.local ? translations.saveLocalVersion : translations.download,
type: widget.closable ? ButtonType.primary : ButtonType.only,
color: FluentTheme.of(context).accentColor,
onTap: () => _startDownload(context),
)
];
void _startDownload(BuildContext context) async {
try {
final topResult = _formKey.currentState?.validate();
if(topResult != true) {
return;
}
final fieldResult = _formFieldKey.currentState?.validate();
if(fieldResult != true) {
return;
}
final build = _build.value;
if(build == null){
return;
}
final source = _source.value;
if(source == _BuildSource.local) {
Navigator.of(context).pop();
_addFortniteVersion(build);
return;
}
_status.value = _DownloadStatus.downloading;
final communicationPort = ReceivePort();
communicationPort.listen((message) {
if(message is FortniteBuildDownloadProgress) {
_onProgress(build, message.progress, message.minutesLeft, message.extracting);
}else if(message is SendPort) {
_downloadPort = message;
}else {
_onDownloadError(message, null);
}
});
final options = FortniteBuildDownloadOptions(
build,
Directory(_pathController.text),
communicationPort.sendPort
);
final errorPort = ReceivePort();
errorPort.listen((message) => _onDownloadError(message, null));
_isolate = await Isolate.spawn(
downloadArchiveBuild,
options,
onError: errorPort.sendPort,
errorsAreFatal: true
);
} catch (exception, stackTrace) {
_onDownloadError(exception, stackTrace);
}
}
Future<void> _onDownloadComplete(FortniteBuild build) async {
if (!mounted) {
return;
}
_status.value = _DownloadStatus.done;
WindowsTaskbar.setProgressMode(TaskbarProgressMode.noProgress);
_addFortniteVersion(build);
}
void _addFortniteVersion(FortniteBuild build) {
WidgetsBinding.instance.addPostFrameCallback((_) => _gameController.addVersion(FortniteVersion(
content: build.version,
location: Directory(_pathController.text)
)));
}
void _onDownloadError(Object? error, StackTrace? stackTrace) {
_cancelDownload();
if (!mounted) {
return;
}
_status.value = _DownloadStatus.error;
WindowsTaskbar.setProgressMode(TaskbarProgressMode.noProgress);
_error = error;
_stackTrace = stackTrace;
}
void _onProgress(FortniteBuild build, double progress, int? timeLeft, bool extracting) {
if (!mounted) {
return;
}
if(progress >= 100 && extracting) {
_onDownloadComplete(build);
return;
}
_status.value = extracting ? _DownloadStatus.extracting : _DownloadStatus.downloading;
if(progress >= 0) {
WindowsTaskbar.setProgress(progress.round(), 100);
}
_timeLeft.value = timeLeft;
_progress.value = progress;
}
Widget get _progressBody {
final timeLeft = _timeLeft.value;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Align(
alignment: Alignment.centerLeft,
child: Text(
_status.value == _DownloadStatus.downloading ? translations.downloading : translations.extracting,
style: FluentTheme.maybeOf(context)?.typography.body,
textAlign: TextAlign.start,
),
),
const SizedBox(
height: 8.0,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
translations.buildProgress((_progress.value ?? 0).round()),
style: FluentTheme.maybeOf(context)?.typography.body,
),
if(timeLeft != null)
Text(
translations.timeLeft(timeLeft),
style: FluentTheme.maybeOf(context)?.typography.body,
)
],
),
const SizedBox(
height: 8.0,
),
SizedBox(
width: double.infinity,
child: ProgressBar(value: (_progress.value ?? 0).toDouble())
),
const SizedBox(
height: 8.0,
)
],
);
}
Widget _buildFormBody(List<FortniteBuild> builds) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSourceSelector(),
const SizedBox(
height: 16.0
),
_buildBuildSelector(builds),
FileSelector(
label: translations.gameFolderTitle,
placeholder: _source.value == _BuildSource.local ? translations.gameFolderPlaceholder : translations.buildInstallationDirectoryPlaceholder,
windowTitle: _source.value == _BuildSource.local ? translations.gameFolderPlaceWindowTitle : translations.buildInstallationDirectoryWindowTitle,
controller: _pathController,
validator: _source.value == _BuildSource.local ? _checkGameFolder : _checkDownloadDestination,
folder: true
),
const SizedBox(
height: 16.0
)
],
);
String? _checkGameFolder(text) {
if (text == null || text.isEmpty) {
return translations.emptyGamePath;
}
final directory = Directory(text);
if (!directory.existsSync()) {
return translations.directoryDoesNotExist;
}
if (FortniteVersionExtension.findFile(directory, "FortniteClient-Win64-Shipping.exe") == null) {
return translations.missingShippingExe;
}
return null;
}
String? _checkDownloadDestination(text) {
if (text == null || text.isEmpty) {
return translations.invalidDownloadPath;
}
return null;
}
Widget _buildBuildSelector(List<FortniteBuild> builds) => InfoLabel(
label: translations.build,
child: FormField<FortniteBuild?>(
key: _formFieldKey,
validator: (data) => _checkBuild(data),
builder: (formContext) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ComboBox<FortniteBuild>(
placeholder: Text(translations.selectBuild),
isExpanded: true,
items: (_source.value == _BuildSource.local ? builds : builds.where((build) => build.available)).map((element) => _buildBuildItem(element)).toList(),
value: _build.value,
onChanged: (value) {
if(value == null){
return;
}
_build.value = value;
formContext.didChange(value);
formContext.validate();
_updateFormDefaults();
}
),
if(formContext.hasError)
const SizedBox(height: 4.0),
if(formContext.hasError)
Text(
formContext.errorText ?? "",
style: TextStyle(
color: Colors.red.defaultBrushFor(FluentTheme.of(context).brightness)
),
),
SizedBox(
height: formContext.hasError ? 8.0 : 16.0
),
],
)
)
);
String? _checkBuild(FortniteBuild? data) {
if(data == null) {
return translations.selectBuild;
}
final versions = _gameController.versions.value;
if (versions.any((element) => data.version == element.content)) {
return translations.versionAlreadyExists;
}
return null;
}
ComboBoxItem<FortniteBuild> _buildBuildItem(FortniteBuild element) => ComboBoxItem<FortniteBuild>(
value: element,
child: Text(element.version.toString())
);
Widget _buildSourceSelector() => InfoLabel(
label: translations.source,
child: ComboBox<_BuildSource>(
placeholder: Text(translations.selectBuild),
isExpanded: true,
items: _BuildSource.values.map((entry) => _buildSourceItem(entry)).toList(),
value: _source.value,
onChanged: (value) {
if(value == null){
return;
}
_source.value = value;
_updateFormDefaults();
}
)
);
ComboBoxItem<_BuildSource> _buildSourceItem(_BuildSource element) => ComboBoxItem<_BuildSource>(
value: element,
child: Text(element.translatedName)
);
List<DialogButton> get _stopButton => [
DialogButton(
text: translations.stopLoadingDialogAction,
type: ButtonType.only
)
];
Future<void> _updateFormDefaults() async {
if(_source.value != _BuildSource.local && _build.value?.available != true) {
_build.value = null;
}
if(_source.value != _BuildSource.local && _diskSpace.disks.isNotEmpty) {
await _fetchFuture;
final bestDisk = _diskSpace.disks
.reduce((first, second) => first.availableSpace > second.availableSpace ? first : second);
final build = _build.value;
if(build == null){
return;
}
final pathText = "${bestDisk.devicePath}\\FortniteBuilds\\${build.version}";
_pathController.text = pathText;
_pathController.selection = TextSelection.collapsed(offset: pathText.length);
}
_formKey.currentState?.validate();
}
}
enum _DownloadStatus {
form,
downloading,
extracting,
error,
done
}
enum _BuildSource {
local,
githubArchive;
String get translatedName {
switch(this) {
case _BuildSource.local:
return translations.localBuild;
case _BuildSource.githubArchive:
return translations.githubArchive;
}
}
}