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

@@ -129,7 +129,7 @@
"importVersionDescription": "Import a new version of Fortnite into the launcher",
"addLocalBuildName": "Add a version from this PC's local storage",
"addLocalBuildDescription": "Versions coming from your local disk are not guaranteed to work",
"addLocalBuildContent": "Add local build",
"addVersion": "Add version",
"downloadBuildName": "Download any version from the cloud",
"downloadBuildDescription": "Download any Fortnite build easily from the cloud",
"downloadBuildContent": "Download build",
@@ -151,8 +151,10 @@
"downloadDllError": "An error occurred while downloading {name}: {error}",
"downloadDllRetry": "Retry",
"uncaughtErrorMessage": "An uncaught error was thrown: {error}",
"launchingHeadlessServer": "Launching the game server...",
"launchingGameClient": "Launching the game client...",
"launchingGameServer": "Launching the game server...",
"launchingGameClientOnly": "Launching the game client without a server...",
"launchingGameClientAndServer": "Launching the game client and server...",
"startGameServer": "Start a game server",
"usernameOrEmail": "Username/Email",
"usernameOrEmailPlaceholder": "Type your username or email",
"password": "Password",
@@ -167,16 +169,16 @@
"stoppingServer": "Stopping the backend...",
"stoppedServer": "The backend was stopped successfully",
"stopServerError": "An error occurred while stopping the backend: {error}",
"missingHostNameError": "Missing hostname in the {name} configuration",
"missingHostNameError": "Missing hostname in the backend configuration",
"missingPortError": "Missing port in the backend configuration",
"illegalPortError": "Invalid port in the backend configuration",
"freeingPort": "Freeing the backend port...",
"freedPort": "The backend port was freed successfully",
"freePortError": "An error occurred while freeing the backend port: {error}",
"pingingRemoteServer": "Pinging the remote backend...",
"pingingLocalServer": "Pinging the {type} backend...",
"pingingServer": "Pinging the {type} backend...",
"pingError": "Cannot ping the {type} backend",
"joinSelfServer": "You can't join your own server",
"cannotJoinServerVersion": "You can't join this server: download Fortnite {version}",
"wrongServerPassword": "Wrong password: please try again",
"offlineServer": "This server isn't online right now: please try again later",
"serverPassword": "Password",
@@ -192,12 +194,12 @@
"deleteVersionCancel": "Keep",
"deleteVersionConfirm": "Delete",
"versionName": "Name",
"versionNameLabel": "Type the new version name",
"versionNameLabel": "Type the version name",
"newVersionNameConfirm": "Save",
"newVersionNameLabel": "Type the new version name",
"gameFolderTitle": "Game folder",
"gameFolderPlaceholder": "Type the new game folder",
"gameFolderPlaceWindowTitle": "Select game folder",
"newVersionNameLabel": "Type the version name",
"gameFolderTitle": "Game directory",
"gameFolderPlaceholder": "Type the game directory",
"gameFolderPlaceWindowTitle": "Select game directory",
"gameFolderLabel": "Path",
"openInExplorer": "Open in explorer",
"modify": "Modify",
@@ -220,7 +222,7 @@
"buildInstallationDirectoryWindowTitle": "Select installation directory",
"timeLeft": "Time left: {timeLeft, plural, =0{less than a minute} =1{about {timeLeft} minute} other{about {timeLeft} minutes}}",
"localBuildsWarning": "Local builds are not guaranteed to work",
"saveLocalVersion": "Save",
"saveLocalVersion": "Add",
"embedded": "Embedded",
"remote": "Remote",
"local": "Local",
@@ -244,7 +246,7 @@
"versionAlreadyExists": "This version already exists",
"emptyGamePath": "Empty game path",
"directoryDoesNotExist": "Directory doesn't exist",
"missingShippingExe": "Invalid game path: missing FortniteClient-Win64-Shipping",
"missingShippingExe": "Invalid game path: missing Fortnite executable",
"invalidDownloadPath": "Invalid download path",
"invalidDllPath": "Invalid dll path",
"dllDoesNotExist": "The file doesn't exist",
@@ -256,9 +258,9 @@
"missingExecutableError": "Missing Fortnite executable: usually this means that the installation was moved or deleted",
"corruptedVersionError": "Corrupted Fortnite installation: please download it again from the launcher or change version",
"corruptedDllError": "Cannot inject dll: {error}",
"missingCustomDllError": "The custom {dll}.dll doesn't exist: check your settings",
"tokenError": "Cannot log in into Fortnite: authentication error (injected dlls: {dlls})",
"unknownFortniteError": "An unknown error occurred while launching Fortnite: {error}",
"serverNoLongerAvailable": "{owner}'s server is no longer available",
"serverNoLongerAvailableUnnamed": "The previous server is no longer available",
"noServerFound": "No server found: invalid or expired link",
"settingsUtilsThemeName": "Theme",
@@ -268,8 +270,6 @@
"system": "System",
"settingsUtilsLanguageName": "Language",
"settingsUtilsLanguageDescription": "Select the language to use inside the launcher",
"playAutomaticServerName": "Embedded game server",
"playAutomaticServerDescription": "Whether a game server should be started automatically if none was configured",
"infoDocumentationName": "Documentation",
"infoDocumentationDescription": "Read some tutorials on how to use Reboot",
"infoDocumentationContent": "Open GitHub",
@@ -277,8 +277,8 @@
"infoDiscordDescription": "Join the discord server to receive help",
"infoDiscordContent": "Open Discord",
"infoVideoName": "Tutorial",
"infoVideoDescription": "Watch a tutorial to understand how to use the launcher",
"infoVideoContent": "Open YouTube",
"infoVideoDescription": "Show the tutorial again in the launcher",
"infoVideoContent": "Start Tutorial",
"dllDeletedTitle": "A critical dll was deleted. If you didn't delete it, your Antivirus probably flagged it. This is a false positive: please disable your Antivirus and try again",
"dllDeletedSecondaryAction": "Close",
"dllDeletedPrimaryAction": "Try again",
@@ -307,5 +307,61 @@
"gameServerTypeDescription": "The type of game server to use",
"gameServerTypeHeadless": "Background process",
"gameServerTypeVirtualWindow": "Virtual window",
"gameServerTypeWindow": "Normal window"
"gameServerTypeWindow": "Normal window",
"localBuild": "This PC",
"githubArchive": "Cloud archive",
"all": "All",
"accessible": "Accessible",
"playable": "Playable",
"timeDescending": "Time (from newest to oldest)",
"timeAscending": "Time (from oldest to newest)",
"nameAscending": "Name (from A to Z)",
"nameDescending": "Name (from Z to A)",
"none": "none",
"openLog": "Open log",
"backendProcessError": "The backend shut down unexpectedly",
"welcomeTitle": "Welcome to Reboot Launcher",
"welcomeDescription": "If you have never used a Fortnite game server, or this launcher in particular, please click on take a tour\nPlease don't ask for support on Discord without taking the tour: this helps me prioritize real bugs\nYou can always take the tour again in the Info tab",
"welcomeAction": "Take the tour",
"startOnboardingText": "Start by choosing a username: this will be visible to other players on Fortnite.\nIf you are advanced user, you can set the email and password here if the backend\nyou are using supports authentication.",
"startOnboardingActionLabel": "Let's do it",
"promptPlayPageText": "The Play tab is used to launch the version of Fortnite you want.\nBefore playing, you'll need to host or join a game server.\nYou will learn how to later.",
"promptPlayPageActionLabel": "Next",
"promptPlayVersionText": "Here you can download or import any Fortnite version\nAdd at least one to start using the launcher",
"promptPlayVersionActionLabelHasBuilds": "Next",
"promptPlayVersionActionLabelNoBuilds": "Let's do it",
"promptServerBrowserPageText": "The Server Browser tab is used to find game servers hosted by other players\nServers can be free to join or password protected based on the settings set by the owner",
"promptServerBrowserPageActionLabel": "Next",
"promptHostPageText": "The Host tab is used to host a game server.\nWhen you usually play Fortnite, you connect to an Epic Games' game server.\nTo play using Reboot, you'll need to host the game server yourself, or join someone else's.\nOtherwise, you will be sent back to the lobby when trying to join a game.",
"promptHostPageActionLabel": "Next",
"promptHostInfoText": "This section is used to provide information about your game server for the Server Browser\nIf you don't want other players to join your server, you can skip this part",
"promptHostInfoActionLabelSkip": "Skip",
"promptHostInfoActionLabelConfigure": "Configure",
"promptHostInformationText": "Choose the name for your server",
"promptHostInformationActionLabel": "Next",
"promptHostInformationDescriptionText": "Choose the description for your server",
"promptHostInformationDescriptionActionLabel": "Next",
"promptHostInformationPasswordText": "Set a password for your server, if you need one",
"promptHostInformationPasswordActionLabel": "Next",
"promptHostVersionText": "You can select the version of Fortnite to host here.\nThese are synchronized with the Play tab.",
"promptHostVersionActionLabel": "Next",
"promptHostShareText": "If you don't want to use the server browser, other players can join\nyou server by using your Reboot Launcher link or your public IP.",
"promptHostShareActionLabel": "Next",
"promptBackendPageText": "The Backend tab is used for authentication and queuing.\nWhen you usually play Fortnite, you connect to an Epic Games' backend.\nTo play using Reboot, you'll need to host the backend yourself, or join someone else's.\nIf the backend doesn't work correctly, an authentication error will be displayed.",
"promptBackendPageActionLabel": "Next",
"promptBackendTypePageText": "By default, an embedded LawinV1 backend is started.\nIf you want to run another backend on your PC, like\nLawinV2 or Momentum, select Local. If you want to join,\na backend on someone else's PC, select Remote.",
"promptBackendTypePageActionLabel": "Next",
"promptBackendGameServerAddressText": "When you are using an embedded backend, you can type\nhere the IP of the game server you want to join. When\nyou click Join in the Server Browser, this field will be\nautocompleted. If you are not using an embedded backend,\nyou will need to set the IP manually in your backend configuration.",
"promptBackendGameServerAddressActionLabel": "Next",
"promptBackendUnrealEngineKeyText": "For some Fortnite versions, the PLAY button doesn't work: when this happens,\nyou need to click this Key to open the Unreal Engine console and type: open IP.\nSo for example if you want to join your own server you can type: open 127.0.0.1.\nIf you don't know, 127.0.0.1 is the IP of your local machine. If you are not using\nthe embedded backend, you'll need to set the Unreal Engine key in its configuration.",
"promptBackendUnrealEngineKeyActionLabel": "Next",
"promptBackendDetachedText": "If you get an authentication error when trying to log into Fortnite,\nswitch to embedded backend and enable this option to debug the backend.\nIf you can't fix the error, report a bug on Discord.",
"promptBackendDetachedActionLabel": "Next",
"promptInfoTabText": "The Info tab contains useful links to report bugs and receive support",
"promptInfoTabActionLabel": "Next",
"promptSettingsTabText": "The Settings tab contains options to customize and reset the launcher",
"promptSettingsTabActionLabel": "Done",
"automaticGameServerDialogContent": "The launcher detected that you are not running a game server, but that your matchmaker is set to your local machine. If you don't want to join another player's server, you should start a game server. This is necessary to be able to play!",
"automaticGameServerDialogIgnore": "Ignore",
"automaticGameServerDialogStart": "Start server"
}

View File

@@ -1,10 +1,8 @@
import 'dart:async';
import 'dart:io';
import 'package:app_links/app_links.dart';
import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_acrylic/flutter_acrylic.dart';
import 'package:flutter_gen/gen_l10n/reboot_localizations.dart';
import 'package:flutter_localized_locales/flutter_localized_locales.dart';
@@ -14,26 +12,18 @@ import 'package:local_notifier/local_notifier.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/src/controller/backend_controller.dart';
import 'package:reboot_launcher/src/controller/build_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/controller/update_controller.dart';
import 'package:reboot_launcher/src/dialog/abstract/info_bar.dart';
import 'package:reboot_launcher/src/dialog/implementation/error.dart';
import 'package:reboot_launcher/src/dialog/implementation/server.dart';
import 'package:reboot_launcher/src/messenger/implementation/error.dart';
import 'package:reboot_launcher/src/messenger/implementation/server.dart';
import 'package:reboot_launcher/src/page/implementation/home_page.dart';
import 'package:reboot_launcher/src/page/implementation/info_page.dart';
import 'package:reboot_launcher/src/util/log.dart';
import 'package:reboot_launcher/src/util/matchmaker.dart';
import 'package:reboot_launcher/src/util/os.dart';
import 'package:reboot_launcher/src/util/translations.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:system_theme/system_theme.dart';
import 'package:url_protocol/url_protocol.dart';
import 'package:version/version.dart';
import 'package:window_manager/window_manager.dart';
import 'package:win32/win32.dart';
const double kDefaultWindowWidth = 1164;
const double kDefaultWindowHeight = 864;
@@ -45,8 +35,8 @@ bool appWithNoStorage = false;
void main() {
log("[APP] Called");
runZonedGuarded(
() => _startApp(),
(error, stack) => onError(error, stack, false),
() => _startApp(),
(error, stack) => onError(error, stack, false),
zoneSpecification: ZoneSpecification(
handleUncaughtError: (self, parent, zone, error, stacktrace) => onError(error, stacktrace, false)
)
@@ -54,6 +44,7 @@ void main() {
}
Future<void> _startApp() async {
_overrideHttpCertificate();
final errors = <Object>[];
try {
log("[APP] Starting application");
@@ -72,11 +63,6 @@ Future<void> _startApp() async {
errors.add(notificationsError);
}
final tilesError = InfoPage.initInfoTiles();
if(tilesError != null) {
errors.add(tilesError);
}
final versionError = await _initVersion();
if(versionError != null) {
errors.add(versionError);
@@ -103,6 +89,18 @@ Future<void> _startApp() async {
}
}
class _MyHttpOverrides extends HttpOverrides {
@override
HttpClient createHttpClient(SecurityContext? context){
return super.createHttpClient(context)
..badCertificateCallback = ((X509Certificate cert, String host, int port) => true);
}
}
void _overrideHttpCertificate() {
HttpOverrides.global = _MyHttpOverrides(); // Not safe, but necessary
}
Future<Object?> _initNotifications() async {
try {
await localNotifier.setup(
@@ -174,11 +172,12 @@ void _initWindow() => doWhenWindowReady(() async {
appWindow.alignment = Alignment.center;
}
appWindow.minSize = const Size(kDefaultWindowWidth, kDefaultWindowHeight);
if(isWin11) {
await Window.setEffect(
effect: WindowEffect.acrylic,
color: Colors.transparent,
dark: SchedulerBinding.instance.platformDispatcher.platformBrightness.isDark
dark: isDarkMode
);
}
}catch(error, stackTrace) {
@@ -191,11 +190,10 @@ void _initWindow() => doWhenWindowReady(() async {
Future<List<Object>> _initStorage() async {
final errors = <Object>[];
try {
await GetStorage("game", settingsDirectory.path).initStorage;
await GetStorage("backend", settingsDirectory.path).initStorage;
await GetStorage("update", settingsDirectory.path).initStorage;
await GetStorage("settings", settingsDirectory.path).initStorage;
await GetStorage("hosting", settingsDirectory.path).initStorage;
await GetStorage("game_storage", settingsDirectory.path).initStorage;
await GetStorage("backend_storage", settingsDirectory.path).initStorage;
await GetStorage("settings_storage", settingsDirectory.path).initStorage;
await GetStorage("hosting_storage", settingsDirectory.path).initStorage;
}catch(error) {
appWithNoStorage = true;
errors.add("The Reboot Launcher configuration in ${settingsDirectory.path} cannot be accessed: running with in memory storage");
@@ -214,19 +212,9 @@ Future<List<Object>> _initStorage() async {
}
try {
Get.put(BuildController());
}catch(error) {
errors.add(error);
}
try {
Get.put(HostingController());
}catch(error) {
errors.add(error);
}
try {
Get.put(UpdateController());
final controller = HostingController();
Get.put(controller);
controller.discardServer();
}catch(error) {
errors.add(error);
}

View File

@@ -14,7 +14,6 @@ class BackendController extends GetxController {
late final Rx<ServerType> type;
late final TextEditingController gameServerAddress;
late final FocusNode gameServerAddressFocusNode;
late final RxnString gameServerOwner;
late final RxBool started;
late final RxBool detached;
StreamSubscription? worker;
@@ -22,7 +21,7 @@ class BackendController extends GetxController {
HttpServer? remoteServer;
BackendController() {
storage = appWithNoStorage ? null : GetStorage("backend");
storage = appWithNoStorage ? null : GetStorage("backend_storage");
started = RxBool(false);
type = Rx(ServerType.values.elementAt(storage?.read("type") ?? 0));
type.listen((value) {
@@ -64,8 +63,10 @@ class BackendController extends GetxController {
}
});
gameServerAddressFocusNode = FocusNode();
gameServerOwner = RxnString(storage?.read("game_server_owner"));
gameServerOwner.listen((value) => storage?.write("game_server_owner", value));
}
void joinLocalhost() {
gameServerAddress.text = kDefaultGameServerHost;
}
void reset() async {
@@ -147,12 +148,11 @@ class BackendController extends GetxController {
switch(type()){
case ServerType.embedded:
final process = await startEmbeddedBackend(detached.value);
final processPid = process.pid;
watchProcess(processPid).then((value) {
if(started()) {
started.value = false;
}
});
watchProcess(process.pid)
.asStream()
.asBroadcastStream()
.where((_) => !started())
.map((_) => ServerResult(ServerResultType.processError));
break;
case ServerType.remote:
yield ServerResult(ServerResultType.pingingRemote);

View File

@@ -1,22 +0,0 @@
import 'package:get/get.dart';
import 'package:reboot_common/common.dart';
class BuildController extends GetxController {
List<FortniteBuild>? _builds;
Rxn<FortniteBuild> _selectedBuild;
BuildController() : _selectedBuild = Rxn();
List<FortniteBuild>? get builds => _builds;
FortniteBuild? get selectedBuild => _selectedBuild.value;
set selectedBuild(FortniteBuild? value) {
_selectedBuild.value = value;
}
set builds(List<FortniteBuild>? builds) {
_builds = builds;
_selectedBuild.value = builds?.firstOrNull;
}
}

View File

@@ -25,7 +25,7 @@ class GameController extends GetxController {
late final Rx<PhysicalKeyboardKey> consoleKey;
GameController() {
_storage = appWithNoStorage ? null : GetStorage("game");
_storage = appWithNoStorage ? null : GetStorage("game_storage");
Iterable decodedVersionsJson = jsonDecode(_storage?.read("versions") ?? "[]");
final decodedVersions = decodedVersionsJson
.map((entry) => FortniteVersion.fromJson(entry))
@@ -33,8 +33,7 @@ class GameController extends GetxController {
versions = Rx(decodedVersions);
versions.listen((data) => _saveVersions());
final decodedSelectedVersionName = _storage?.read("version");
final decodedSelectedVersion = decodedVersions.firstWhereOrNull((
element) => element.name == decodedSelectedVersionName);
final decodedSelectedVersion = decodedVersions.firstWhereOrNull((element) => element.content.toString() == decodedSelectedVersionName);
_selectedVersion = Rxn(decodedSelectedVersion);
username = TextEditingController(
text: _storage?.read("username") ?? kDefaultPlayerName);
@@ -88,7 +87,7 @@ class GameController extends GetxController {
}
FortniteVersion? getVersionByName(String name) {
return versions.value.firstWhereOrNull((element) => element.name == name);
return versions.value.firstWhereOrNull((element) => element.content.toString() == name);
}
void addVersion(FortniteVersion version) {
@@ -99,15 +98,9 @@ class GameController extends GetxController {
}
}
FortniteVersion removeVersionByName(String versionName) {
var version = versions.value.firstWhere((element) => element.name == versionName);
removeVersion(version);
return version;
}
void removeVersion(FortniteVersion version) {
versions.update((val) => val?.remove(version));
if (selectedVersion?.name == version.name || hasNoVersions) {
if (selectedVersion == version || hasNoVersions) {
selectedVersion = null;
}
}
@@ -125,7 +118,7 @@ class GameController extends GetxController {
set selectedVersion(FortniteVersion? version) {
_selectedVersion.value = version;
_storage?.write("version", version?.name);
_storage?.write("version", version?.content.toString());
}
void updateVersion(FortniteVersion version, Function(FortniteVersion) function) {

View File

@@ -1,17 +1,25 @@
import 'dart:convert';
import 'package:dart_ipify/dart_ipify.dart';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/main.dart';
import 'package:reboot_launcher/src/util/cryptography.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:sync/semaphore.dart';
import 'package:uuid/uuid.dart';
class HostingController extends GetxController {
late final GetStorage? _storage;
late final String uuid;
late final TextEditingController name;
late final FocusNode nameFocusNode;
late final TextEditingController description;
late final FocusNode descriptionFocusNode;
late final TextEditingController password;
late final FocusNode passwordFocusNode;
late final RxBool showPassword;
late final RxBool discoverable;
late final Rx<GameServerType> type;
@@ -19,10 +27,11 @@ class HostingController extends GetxController {
late final RxBool started;
late final RxBool published;
late final Rxn<GameInstance> instance;
late final Rxn<Set<Map<String, dynamic>>> servers;
late final Rxn<Set<FortniteServer>> servers;
late final Semaphore _semaphore;
HostingController() {
_storage = appWithNoStorage ? null : GetStorage("hosting");
_storage = appWithNoStorage ? null : GetStorage("hosting_storage");
uuid = _storage?.read("uuid") ?? const Uuid().v4();
_storage?.write("uuid", uuid);
name = TextEditingController(text: _storage?.read("name"));
@@ -31,6 +40,9 @@ class HostingController extends GetxController {
description.addListener(() => _storage?.write("description", description.text));
password = TextEditingController(text: _storage?.read("password") ?? "");
password.addListener(() => _storage?.write("password", password.text));
nameFocusNode = FocusNode();
descriptionFocusNode = FocusNode();
passwordFocusNode = FocusNode();
discoverable = RxBool(_storage?.read("discoverable") ?? false);
discoverable.listen((value) => _storage?.write("discoverable", value));
type = Rx(GameServerType.values.elementAt(_storage?.read("type") ?? GameServerType.headless.index));
@@ -43,16 +55,80 @@ class HostingController extends GetxController {
instance = Rxn();
final supabase = Supabase.instance.client;
servers = Rxn();
supabase.from("hosting")
supabase.from("hosting_v2")
.stream(primaryKey: ['id'])
.map((event) => _parseValidServers(event))
.map((event) => event.map((element) => FortniteServer.fromJson(element)).where((element) => element.ip.isNotEmpty).toSet())
.listen((event) {
servers.value = event;
published.value = event.any((element) => element["id"] == uuid);
published.value = event.any((element) => element.id == uuid);
});
_semaphore = Semaphore();
}
Set<Map<String, dynamic>> _parseValidServers(event) => event.where((element) => element["ip"] != null).toSet();
Future<void> publishServer(String author, String version) async {
try {
_semaphore.acquire();
log("[SERVER] Publishing server...");
if(published.value) {
log("[SERVER] Already published");
return;
}
final passwordText = password.text;
final hasPassword = passwordText.isNotEmpty;
var ip = await Ipify.ipv4();
if(hasPassword) {
ip = aes256Encrypt(ip, passwordText);
}
final supabase = Supabase.instance.client;
final hosts = supabase.from("hosting_v2");
final payload = FortniteServer(
id: uuid,
name: name.text,
description: description.text,
author: author,
ip: ip,
version: version,
password: hasPassword ? hashPassword(passwordText) : null,
timestamp: DateTime.now(),
discoverable: discoverable.value
).toJson();
log("[SERVER] Payload: ${jsonEncode(payload)}");
if(published()) {
await hosts.update(payload)
.eq("id", uuid);
}else {
await hosts.insert(payload);
}
published.value = true;
log("[SERVER] Published");
}catch(error) {
log("[SERVER] Cannot publish server: $error");
published.value = false;
}finally {
_semaphore.release();
}
}
Future<void> discardServer() async {
try {
_semaphore.acquire();
log("[SERVER] Discarding server...");
final supabase = Supabase.instance.client;
await supabase.from("hosting_v2")
.delete()
.match({'id': uuid});
servers.value?.removeWhere((element) => element.id == uuid);
log("[SERVER] Discarded server");
}catch(error) {
log("[SERVER] Cannot discard server: $error");
}finally {
published.value = false;
_semaphore.release();
}
}
void reset() {
name.text = "";
@@ -65,9 +141,9 @@ class HostingController extends GetxController {
autoRestart.value = true;
}
Map<String, dynamic>? findServerById(String uuid) {
FortniteServer? findServerById(String uuid) {
try {
return servers.value?.firstWhere((element) => element["id"] == uuid);
return servers.value?.firstWhere((element) => element.id == uuid);
} on StateError catch(_) {
return null;
}

View File

@@ -1,71 +1,333 @@
import 'dart:async';
import 'dart:io';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart';
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/main.dart';
import 'package:reboot_launcher/src/messenger/abstract/info_bar.dart';
import 'package:reboot_launcher/src/util/translations.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:version/version.dart';
import 'package:yaml/yaml.dart';
class SettingsController extends GetxController {
late final GetStorage _storage;
late final GetStorage? _storage;
late final String originalDll;
late final TextEditingController gameServerDll;
late final TextEditingController unrealEngineConsoleDll;
late final TextEditingController backendDll;
late final TextEditingController memoryLeakDll;
late final TextEditingController gameServerPort;
late final RxBool firstRun;
late final RxString language;
late final Rx<ThemeMode> themeMode;
late final RxnInt timestamp;
late final Rx<UpdateStatus> status;
late final Rx<UpdateTimer> timer;
late final TextEditingController url;
late final RxBool customGameServer;
late final RxBool firstRun;
late final Map<String, Future<bool>> _operations;
late double width;
late double height;
late double? offsetX;
late double? offsetY;
InfoBarEntry? infoBarEntry;
Future<bool>? _updater;
SettingsController() {
_storage = GetStorage("settings");
gameServerDll = _createController("game_server", "reboot.dll");
unrealEngineConsoleDll = _createController("unreal_engine_console", "console.dll");
backendDll = _createController("backend", "cobalt.dll");
memoryLeakDll = _createController("memory_leak", "memory.dll");
gameServerPort = TextEditingController(text: _storage.read("game_server_port") ?? kDefaultGameServerPort);
gameServerPort.addListener(() => _storage.write("game_server_port", gameServerPort.text));
width = _storage.read("width") ?? kDefaultWindowWidth;
height = _storage.read("height") ?? kDefaultWindowHeight;
offsetX = _storage.read("offset_x");
offsetY = _storage.read("offset_y");
firstRun = RxBool(_storage.read("first_run_new1") ?? true);
firstRun.listen((value) => _storage.write("first_run_new1", value));
themeMode = Rx(ThemeMode.values.elementAt(_storage.read("theme") ?? 0));
themeMode.listen((value) => _storage.write("theme", value.index));
language = RxString(_storage.read("language") ?? currentLocale);
language.listen((value) => _storage.write("language", value));
_storage = appWithNoStorage ? null : GetStorage("settings_storage");
gameServerDll = _createController("game_server", InjectableDll.reboot);
unrealEngineConsoleDll = _createController("unreal_engine_console", InjectableDll.console);
backendDll = _createController("backend", InjectableDll.cobalt);
memoryLeakDll = _createController("memory_leak", InjectableDll.memory);
gameServerPort = TextEditingController(text: _storage?.read("game_server_port") ?? kDefaultGameServerPort);
gameServerPort.addListener(() => _storage?.write("game_server_port", gameServerPort.text));
width = _storage?.read("width") ?? kDefaultWindowWidth;
height = _storage?.read("height") ?? kDefaultWindowHeight;
offsetX = _storage?.read("offset_x");
offsetY = _storage?.read("offset_y");
themeMode = Rx(ThemeMode.values.elementAt(_storage?.read("theme") ?? 0));
themeMode.listen((value) => _storage?.write("theme", value.index));
language = RxString(_storage?.read("language") ?? currentLocale);
language.listen((value) => _storage?.write("language", value));
timestamp = RxnInt(_storage?.read("ts"));
timestamp.listen((value) => _storage?.write("ts", value));
final timerIndex = _storage?.read("timer");
timer = Rx(timerIndex == null ? UpdateTimer.hour : UpdateTimer.values.elementAt(timerIndex));
timer.listen((value) => _storage?.write("timer", value.index));
url = TextEditingController(text: _storage?.read("update_url") ?? kRebootDownloadUrl);
url.addListener(() => _storage?.write("update_url", url.text));
status = Rx(UpdateStatus.waiting);
customGameServer = RxBool(_storage?.read("custom_game_server") ?? false);
customGameServer.listen((value) => _storage?.write("custom_game_server", value));
firstRun = RxBool(_storage?.read("first_run_tutorial") ?? true);
firstRun.listen((value) => _storage?.write("first_run_tutorial", value));
_operations = {};
}
TextEditingController _createController(String key, String name) {
var controller = TextEditingController(text: _storage.read(key) ?? _controllerDefaultPath(name));
controller.addListener(() => _storage.write(key, controller.text));
TextEditingController _createController(String key, InjectableDll dll) {
final controller = TextEditingController(text: _storage?.read(key) ?? _getDefaultPath(dll));
controller.addListener(() => _storage?.write(key, controller.text));
return controller;
}
void saveWindowSize(Size size) {
_storage.write("width", size.width);
_storage.write("height", size.height);
_storage?.write("width", size.width);
_storage?.write("height", size.height);
}
void saveWindowOffset(Offset position) {
offsetX = position.dx;
offsetY = position.dy;
_storage.write("offset_x", offsetX);
_storage.write("offset_y", offsetY);
_storage?.write("offset_x", offsetX);
_storage?.write("offset_y", offsetY);
}
void reset(){
gameServerDll.text = _controllerDefaultPath("reboot.dll");
unrealEngineConsoleDll.text = _controllerDefaultPath("console.dll");
backendDll.text = _controllerDefaultPath("cobalt.dll");
gameServerDll.text = _getDefaultPath(InjectableDll.reboot);
unrealEngineConsoleDll.text = _getDefaultPath(InjectableDll.console);
backendDll.text = _getDefaultPath(InjectableDll.cobalt);
memoryLeakDll.text = _getDefaultPath(InjectableDll.memory);
gameServerPort.text = kDefaultGameServerPort;
firstRun.value = true;
timestamp.value = null;
timer.value = UpdateTimer.never;
url.text = kRebootDownloadUrl;
status.value = UpdateStatus.waiting;
customGameServer.value = false;
updateReboot();
}
String _controllerDefaultPath(String name) => "${dllsDirectory.path}\\$name";
Future<void> notifyLauncherUpdate() async {
if(appVersion == null) {
return;
}
final pubspec = await _getPubspecYaml();
if(pubspec == null) {
return;
}
final latestVersion = Version.parse(pubspec["version"]);
if(latestVersion <= appVersion) {
return;
}
late InfoBarEntry infoBar;
infoBar = showRebootInfoBar(
translations.updateAvailable(latestVersion.toString()),
duration: null,
severity: InfoBarSeverity.warning,
action: Button(
child: Text(translations.updateAvailableAction),
onPressed: () {
infoBar.close();
launchUrl(Uri.parse("https://github.com/Auties00/reboot_launcher/releases"));
},
)
);
}
Future<dynamic> _getPubspecYaml() async {
try {
final pubspecResponse = await http.get(Uri.parse("https://raw.githubusercontent.com/Auties00/reboot_launcher/master/gui/pubspec.yaml"));
if(pubspecResponse.statusCode != 200) {
return null;
}
return loadYaml(pubspecResponse.body);
}catch(error) {
log("[UPDATER] Cannot check for updates: $error");
return null;
}
}
Future<bool> updateReboot({bool force = false, bool silent = false}) async {
if(_updater != null) {
return await _updater!;
}
final result = _updateReboot(force, silent);
_updater = result;
return await result;
}
Future<bool> _updateReboot(bool force, bool silent) async {
try {
if(customGameServer.value) {
status.value = UpdateStatus.success;
return true;
}
final needsUpdate = await hasRebootDllUpdate(
timestamp.value,
hours: timer.value.hours,
force: force
);
if(!needsUpdate) {
status.value = UpdateStatus.success;
return true;
}
if(!silent) {
infoBarEntry = showRebootInfoBar(
translations.downloadingDll("reboot"),
loading: true,
duration: null
);
}
timestamp.value = await downloadRebootDll(url.text);
status.value = UpdateStatus.success;
infoBarEntry?.close();
if(!silent) {
infoBarEntry = showRebootInfoBar(
translations.downloadDllSuccess("reboot"),
severity: InfoBarSeverity.success,
duration: infoBarShortDuration
);
}
return true;
}catch(message) {
infoBarEntry?.close();
var error = message.toString();
error = error.contains(": ") ? error.substring(error.indexOf(": ") + 2) : error;
error = error.toLowerCase();
status.value = UpdateStatus.error;
showRebootInfoBar(
translations.downloadDllError("reboot.dll", error.toString()),
duration: infoBarLongDuration,
severity: InfoBarSeverity.error,
action: Button(
onPressed: () => updateReboot(
force: true,
silent: silent
),
child: Text(translations.downloadDllRetry),
)
);
return false;
}finally {
_updater = null;
}
}
(File, bool) getInjectableData(InjectableDll dll) {
final defaultPath = canonicalize(_getDefaultPath(dll));
switch(dll){
case InjectableDll.reboot:
if(customGameServer.value) {
final file = File(gameServerDll.text);
if(file.existsSync()) {
return (file, true);
}
}
return (rebootDllFile, false);
case InjectableDll.console:
final ue4ConsoleFile = File(unrealEngineConsoleDll.text);
return (ue4ConsoleFile, canonicalize(ue4ConsoleFile.path) != defaultPath);
case InjectableDll.cobalt:
final backendFile = File(backendDll.text);
return (backendFile, canonicalize(backendFile.path) != defaultPath);
case InjectableDll.memory:
final memoryLeakFile = File(memoryLeakDll.text);
return (memoryLeakFile, canonicalize(memoryLeakFile.path) != defaultPath);
}
}
String _getDefaultPath(InjectableDll dll) => "${dllsDirectory.path}\\${dll.name}.dll";
Future<bool> downloadCriticalDllInteractive(String filePath, {bool silent = false}) {
log("[DLL] Asking for $filePath(silent: $silent)");
final old = _operations[filePath];
if(old != null) {
log("[DLL] Download task already exists");
return old;
}
log("[DLL] Creating new download task...");
final newRun = _downloadCriticalDllInteractive(filePath, silent);
_operations[filePath] = newRun;
return newRun;
}
Future<bool> _downloadCriticalDllInteractive(String filePath, bool silent) async {
final fileName = basename(filePath).toLowerCase();
log("[DLL] File name: $fileName");
InfoBarEntry? entry;
try {
if (fileName == "reboot.dll") {
log("[DLL] Downloading reboot.dll...");
return await updateReboot(
silent: silent
);
}
if(File(filePath).existsSync()) {
log("[DLL] File already exists");
return true;
}
final fileNameWithoutExtension = basenameWithoutExtension(filePath);
if(!silent) {
entry = showRebootInfoBar(
translations.downloadingDll(fileNameWithoutExtension),
loading: true,
duration: null
);
}
await downloadCriticalDll(fileName, filePath);
entry?.close();
if(!silent) {
entry = await showRebootInfoBar(
translations.downloadDllSuccess(fileNameWithoutExtension),
severity: InfoBarSeverity.success,
duration: infoBarShortDuration
);
}
return true;
}catch(message) {
log("[DLL] Error: $message");
entry?.close();
var error = message.toString();
error = error.contains(": ") ? error.substring(error.indexOf(": ") + 2) : error;
error = error.toLowerCase();
final completer = Completer();
await showRebootInfoBar(
translations.downloadDllError(fileName, error.toString()),
duration: infoBarLongDuration,
severity: InfoBarSeverity.error,
onDismissed: () => completer.complete(null),
action: Button(
onPressed: () async {
await downloadCriticalDllInteractive(filePath);
completer.complete(null);
},
child: Text(translations.downloadDllRetry),
)
);
await completer.future;
return false;
}finally {
_operations.remove(fileName);
}
}
}
extension _UpdateTimerExtension on UpdateTimer {
int get hours {
switch(this) {
case UpdateTimer.never:
return -1;
case UpdateTimer.hour:
return 1;
case UpdateTimer.day:
return 24;
case UpdateTimer.week:
return 24 * 7;
}
}
}

View File

@@ -1,161 +0,0 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:http/http.dart' as http;
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/main.dart';
import 'package:reboot_launcher/src/dialog/abstract/info_bar.dart';
import 'package:reboot_launcher/src/util/translations.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:version/version.dart';
import 'package:yaml/yaml.dart';
class UpdateController {
late final GetStorage? _storage;
late final RxnInt timestamp;
late final Rx<UpdateStatus> status;
late final Rx<UpdateTimer> timer;
late final TextEditingController url;
late final RxBool customGameServer;
InfoBarEntry? infoBarEntry;
Future? _updater;
UpdateController() {
_storage = appWithNoStorage ? null : GetStorage("update");
timestamp = RxnInt(_storage?.read("ts"));
timestamp.listen((value) => _storage?.write("ts", value));
var timerIndex = _storage?.read("timer");
timer = Rx(timerIndex == null ? UpdateTimer.hour : UpdateTimer.values.elementAt(timerIndex));
timer.listen((value) => _storage?.write("timer", value.index));
url = TextEditingController(text: _storage?.read("update_url") ?? kRebootDownloadUrl);
url.addListener(() => _storage?.write("update_url", url.text));
status = Rx(UpdateStatus.waiting);
customGameServer = RxBool(_storage?.read("custom_game_server") ?? false);
customGameServer.listen((value) => _storage?.write("custom_game_server", value));
}
Future<void> notifyLauncherUpdate() async {
if(appVersion == null) {
return;
}
final pubspecResponse = await http.get(Uri.parse("https://raw.githubusercontent.com/Auties00/reboot_launcher/master/gui/pubspec.yaml"));
if(pubspecResponse.statusCode != 200) {
return;
}
final pubspec = loadYaml(pubspecResponse.body);
final latestVersion = Version.parse(pubspec["version"]);
if(latestVersion <= appVersion) {
return;
}
late InfoBarEntry infoBar;
infoBar = showInfoBar(
translations.updateAvailable(latestVersion.toString()),
duration: null,
severity: InfoBarSeverity.warning,
action: Button(
child: Text(translations.updateAvailableAction),
onPressed: () {
infoBar.close();
launchUrl(Uri.parse("https://github.com/Auties00/reboot_launcher/releases"));
},
)
);
}
Future<void> updateReboot({bool force = false, bool silent = false}) async {
if(_updater != null) {
return await _updater;
}
final result = _updateReboot(force, silent);
_updater = result;
return await result;
}
Future<void> _updateReboot(bool force, bool silent) async {
try {
if(customGameServer.value) {
status.value = UpdateStatus.success;
return;
}
final needsUpdate = await hasRebootDllUpdate(
timestamp.value,
hours: timer.value.hours,
force: force
);
if(!needsUpdate) {
status.value = UpdateStatus.success;
return;
}
if(!silent) {
infoBarEntry = showInfoBar(
translations.downloadingDll("reboot"),
loading: true,
duration: null
);
}
timestamp.value = await downloadRebootDll(url.text);
status.value = UpdateStatus.success;
infoBarEntry?.close();
if(!silent) {
infoBarEntry = showInfoBar(
translations.downloadDllSuccess("reboot"),
severity: InfoBarSeverity.success,
duration: infoBarShortDuration
);
}
}catch(message) {
if(!silent) {
infoBarEntry?.close();
var error = message.toString();
error =
error.contains(": ") ? error.substring(error.indexOf(": ") + 2) : error;
error = error.toLowerCase();
status.value = UpdateStatus.error;
showInfoBar(
translations.downloadDllError("reboot.dll", error.toString()),
duration: infoBarLongDuration,
severity: InfoBarSeverity.error,
action: Button(
onPressed: () => updateReboot(
force: true,
silent: silent
),
child: Text(translations.downloadDllRetry),
)
);
}
}finally {
_updater = null;
}
}
void reset() {
timestamp.value = null;
timer.value = UpdateTimer.never;
url.text = kRebootDownloadUrl;
status.value = UpdateStatus.waiting;
customGameServer.value = false;
updateReboot();
}
}
extension _UpdateTimerExtension on UpdateTimer {
int get hours {
switch(this) {
case UpdateTimer.never:
return -1;
case UpdateTimer.hour:
return 1;
case UpdateTimer.day:
return 24;
case UpdateTimer.week:
return 24 * 7;
}
}
}

View File

@@ -1,56 +0,0 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:reboot_launcher/src/util/translations.dart';
class DialogButton extends StatefulWidget {
final String? text;
final Function()? onTap;
final ButtonType type;
const DialogButton(
{Key? key,
this.text,
this.onTap,
required this.type})
: assert(type != ButtonType.primary || onTap != null,
"OnTap handler cannot be null for primary buttons"),
assert(type != ButtonType.primary || text != null,
"Text cannot be null for primary buttons"),
super(key: key);
@override
State<DialogButton> createState() => _DialogButtonState();
}
class _DialogButtonState extends State<DialogButton> {
@override
Widget build(BuildContext context) => widget.type == ButtonType.only ? _onlyButton : _button;
SizedBox get _onlyButton => SizedBox(
width: double.infinity,
child: _button
);
Widget get _button => widget.type == ButtonType.primary ? _primaryButton : _secondaryButton;
Widget get _primaryButton {
return Button(
onPressed: widget.onTap!,
child: Text(widget.text!),
);
}
Widget get _secondaryButton {
return Button(
onPressed: widget.onTap ?? _onDefaultSecondaryActionTap,
child: Text(widget.text ?? translations.defaultDialogSecondaryAction),
);
}
void _onDefaultSecondaryActionTap() => Navigator.of(context).pop(null);
}
enum ButtonType {
primary,
secondary,
only
}

View File

@@ -1,21 +1,20 @@
import 'package:clipboard/clipboard.dart';
import 'package:fluent_ui/fluent_ui.dart' as fluent show showDialog;
import 'package:fluent_ui/fluent_ui.dart';
import 'package:reboot_launcher/src/dialog/abstract/info_bar.dart';
import 'package:reboot_launcher/src/messenger/abstract/info_bar.dart';
import 'package:reboot_launcher/src/page/pages.dart';
import 'package:reboot_launcher/src/util/translations.dart';
import 'dialog_button.dart';
bool inDialog = false;
Future<T?> showAppDialog<T extends Object?>({required WidgetBuilder builder}) async {
Future<T?> showRebootDialog<T extends Object?>({required WidgetBuilder builder, bool dismissWithEsc = true}) async {
inDialog = true;
pagesController.add(null);
try {
return await fluent.showDialog(
context: appKey.currentContext!,
context: appNavigatorKey.currentContext!,
useRootNavigator: false,
dismissWithEsc: dismissWithEsc,
builder: builder
);
}finally {
@@ -58,7 +57,7 @@ class FormDialog extends AbstractDialog {
return Form(
child: Builder(
builder: (context) {
var parsed = buttons.map((entry) => _createFormButton(entry, context)).toList();
final parsed = buttons.map((entry) => _createFormButton(entry, context)).toList();
return GenericDialog(
header: content,
buttons: parsed
@@ -117,8 +116,9 @@ class InfoDialog extends AbstractDialog {
class ProgressDialog extends AbstractDialog {
final String text;
final Function()? onStop;
final bool showButton;
const ProgressDialog({required this.text, this.onStop, Key? key}) : super(key: key);
const ProgressDialog({required this.text, this.onStop, this.showButton = true, Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
@@ -132,11 +132,12 @@ class ProgressDialog extends AbstractDialog {
),
),
buttons: [
DialogButton(
text: translations.defaultDialogSecondaryAction,
type: ButtonType.only,
onTap: onStop
)
if(showButton)
DialogButton(
text: translations.defaultDialogSecondaryAction,
type: ButtonType.only,
onTap: onStop
)
]
);
}
@@ -239,7 +240,7 @@ class ErrorDialog extends AbstractDialog {
type: type,
onTap: () async {
FlutterClipboard.controlC("$error\n$stackTrace");
showInfoBar(translations.copyErrorDialogSuccess);
showRebootInfoBar(translations.copyErrorDialogSuccess);
onClick();
},
);
@@ -262,4 +263,62 @@ class ErrorDialog extends AbstractDialog {
],
);
}
}
class DialogButton extends StatefulWidget {
final String? text;
final Function()? onTap;
final ButtonType type;
final Color? color;
const DialogButton(
{Key? key,
this.text,
this.onTap,
this.color,
required this.type})
: assert(type != ButtonType.primary || onTap != null,
"OnTap handler cannot be null for primary buttons"),
assert(type != ButtonType.primary || text != null,
"Text cannot be null for primary buttons"),
super(key: key);
@override
State<DialogButton> createState() => _DialogButtonState();
}
class _DialogButtonState extends State<DialogButton> {
@override
Widget build(BuildContext context) => widget.type == ButtonType.only ? _onlyButton : _button;
SizedBox get _onlyButton => SizedBox(
width: double.infinity,
child: _button
);
Widget get _button => widget.type == ButtonType.primary ? _primaryButton : _secondaryButton;
Widget get _primaryButton => Button(
style: ButtonStyle(
backgroundColor: ButtonState.all(FluentTheme.of(context).accentColor)
),
onPressed: widget.onTap!,
child: Text(widget.text!),
);
Widget get _secondaryButton => Button(
style: widget.color != null ? ButtonStyle(
backgroundColor: ButtonState.all(widget.color!)
) : null,
onPressed: widget.onTap ?? _onDefaultSecondaryActionTap,
child: Text(widget.text ?? translations.defaultDialogSecondaryAction),
);
void _onDefaultSecondaryActionTap() => Navigator.of(context).pop(null);
}
enum ButtonType {
primary,
secondary,
only
}

View File

@@ -5,7 +5,7 @@ const infoBarLongDuration = Duration(seconds: 4);
const infoBarShortDuration = Duration(seconds: 2);
const _height = 64.0;
InfoBarEntry showInfoBar(dynamic text, {
InfoBarEntry showRebootInfoBar(dynamic text, {
InfoBarSeverity severity = InfoBarSeverity.info,
bool loading = false,
Duration? duration = infoBarShortDuration,
@@ -21,33 +21,40 @@ InfoBarEntry showInfoBar(dynamic text, {
return overlayEntry;
}
Widget _buildOverlay(text, Widget? action, bool loading, InfoBarSeverity severity) => SizedBox(
width: double.infinity,
height: _height,
Widget _buildOverlay(text, Widget? action, bool loading, InfoBarSeverity severity) => ConstrainedBox(
constraints: BoxConstraints(
minWidth: double.infinity,
minHeight: _height
),
child: Mica(
child: InfoBar(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if(text is Widget)
text,
if(text is String)
Text(text),
if(action != null)
action
],
),
isLong: false,
isIconVisible: true,
content: SizedBox(
width: double.infinity,
child: loading ? const Padding(
padding: EdgeInsets.only(top: 8.0, bottom: 2.0),
child: ProgressBar(),
) : const SizedBox()
),
severity: severity
),
elevation: 1,
child: InfoBar(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if(text is Widget)
text,
if(text is String)
Text(text),
if(action != null)
action
],
),
isLong: false,
isIconVisible: true,
content: SizedBox(
width: double.infinity,
child: loading ? const Padding(
padding: const EdgeInsets.only(
top: 8.0,
bottom: 2.0,
right: 6.0
),
child: ProgressBar(),
) : const SizedBox()
),
severity: severity
)
),
);

View File

@@ -0,0 +1,170 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:flutter/rendering.dart';
import 'package:reboot_launcher/src/page/implementation/home_page.dart';
import 'package:reboot_launcher/src/page/pages.dart';
typedef WidgetBuilder = Widget Function(BuildContext, void Function());
class OverlayTarget extends StatefulWidget {
final Widget child;
const OverlayTarget({super.key, required this.child});
@override
State<OverlayTarget> createState() => OverlayTargetState();
OverlayTargetState of(BuildContext context) => context.findAncestorStateOfType<OverlayTargetState>()!;
}
class OverlayTargetState extends State<OverlayTarget> {
@override
Widget build(BuildContext context) => widget.child;
void showOverlay({
required String text,
required WidgetBuilder actionBuilder,
Offset offset = Offset.zero,
bool ignoreTargetPointers = true,
AttachMode attachMode = AttachMode.start
}) {
final renderBox = context.findRenderObject() as RenderBox;
final position = renderBox.localToGlobal(Offset.zero);
final color = FluentTheme.of(context).acrylicBackgroundColor;
late OverlayEntry entry;
entry = OverlayEntry(
builder: (context) => Stack(
children: [
Container(
width: double.infinity,
height: double.infinity,
child: _AbsorbPointer(
exclusion: ignoreTargetPointers ? null : renderBox
)
),
Positioned(
left: position.dx - (attachMode != AttachMode.start ? renderBox.size.width : 0) + offset.dx,
top: position.dy + (renderBox.size.height / 2) + offset.dy,
child: CustomPaint(
painter: _CallOutShape(color, attachMode != AttachMode.start),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(text),
const SizedBox(height: 12.0),
actionBuilder(context, () => entry.remove())
],
),
),
),
),
],
)
);
appOverlayKey.currentState?.insert(entry);
}
}
enum AttachMode {
start,
middle,
end;
}
// Harder than one would think
class _CallOutShape extends CustomPainter {
final Color color;
final bool end;
_CallOutShape(this.color, this.end);
@override
void paint(Canvas canvas, Size size) {
final fillPaint = Paint()
..color = color
..style = PaintingStyle.fill;
final borderPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 0.25
..color = Colors.white;
final path = Path();
path.moveTo(10, 0);
if(!end) {
path.lineTo(12.5, 0);
path.lineTo(20, -12.5);
path.lineTo(27.5, 0);
}else {
path.lineTo(size.width - 27.5, 0);
path.lineTo(size.width - 20, -12.5);
path.lineTo(size.width - 12.5, 0);
}
path.lineTo(size.width - 10, 0);
path.arcToPoint(Offset(size.width, 10), radius: Radius.circular(10));
path.lineTo(size.width, size.height - 10);
path.arcToPoint(Offset(size.width - 10, size.height), radius: Radius.circular(10));
path.lineTo(10, size.height);
path.arcToPoint(Offset(0, size.height - 10), radius: Radius.circular(10));
path.lineTo(0, 10);
path.arcToPoint(Offset(10, 0), radius: Radius.circular(10));
path.close();
canvas.drawPath(path, fillPaint);
canvas.drawPath(path, borderPaint);
canvas.drawShadow(path, color, 1, true);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
class _AbsorbPointer extends SingleChildRenderObjectWidget {
final RenderBox? exclusion;
const _AbsorbPointer({
required this.exclusion
});
@override
_RenderAbsorbPointer createRenderObject(BuildContext context) => _RenderAbsorbPointer(
exclusion: exclusion
);
}
class _RenderAbsorbPointer extends RenderProxyBox {
final RenderBox? exclusion;
_RenderAbsorbPointer({
required this.exclusion,
RenderBox? child
}) : super(child);
@override
bool hitTest(BoxHitTestResult result, { required Offset position }) {
final exclusion = this.exclusion;
if(exclusion == null) {
return size.contains(position);
}
// 32 is the height of the title bar (need this offset as the overlay area doesn't include it)
// Not an optimal solution but it works (calculating it is kind of complicated)
position = Offset(position.dx, position.dy + HomePage.kTitleBarHeight);
final exclusionPosition = exclusion.localToGlobal(Offset.zero);
final exclusionSize = Rect.fromLTRB(
exclusionPosition.dx,
exclusionPosition.dy,
exclusionPosition.dx + exclusion.size.width,
exclusionPosition.dy + exclusion.size.height
);
return !exclusionSize.contains(position);
}
@override
void visitChildrenForSemantics(RenderObjectVisitor visitor) => super.visitChildrenForSemantics(visitor);
@override
void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config);
config.isBlockingUserActions = true;
}
}

View File

@@ -1,9 +1,8 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:reboot_launcher/src/dialog/abstract/dialog.dart';
import 'package:reboot_launcher/src/dialog/abstract/dialog_button.dart';
import 'package:reboot_launcher/src/messenger/abstract/dialog.dart';
import 'package:reboot_launcher/src/util/translations.dart';
Future<void> showResetDialog(Function() onConfirm) => showAppDialog(
Future<void> showResetDialog(Function() onConfirm) => showRebootDialog(
builder: (context) => InfoDialog(
text: translations.resetDefaultsDialogTitle,
buttons: [

View File

@@ -1,9 +1,8 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:reboot_launcher/src/dialog/abstract/dialog.dart';
import 'package:reboot_launcher/src/dialog/abstract/dialog_button.dart';
import 'package:reboot_launcher/src/messenger/abstract/dialog.dart';
import 'package:reboot_launcher/src/util/translations.dart';
Future<void> showDllDeletedDialog(Function() onConfirm) => showAppDialog(
Future<void> showDllDeletedDialog(Function() onConfirm) => showRebootDialog(
builder: (context) => InfoDialog(
text: translations.dllDeletedTitle,
buttons: [

View File

@@ -1,12 +1,9 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:flutter/foundation.dart';
import 'package:reboot_launcher/src/dialog/abstract/dialog.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';
import '../../util/log.dart';
String? lastError;
void onError(Object exception, StackTrace? stackTrace, bool framework) {
@@ -21,12 +18,12 @@ void onError(Object exception, StackTrace? stackTrace, bool framework) {
}
lastError = exception.toString();
var route = ModalRoute.of(pageKey.currentContext!);
final route = ModalRoute.of(pageKey.currentContext!);
if(route != null && !route.isCurrent){
Navigator.of(pageKey.currentContext!).pop(false);
}
WidgetsBinding.instance.addPostFrameCallback((timeStamp) => showAppDialog(
WidgetsBinding.instance.addPostFrameCallback((timeStamp) => showRebootDialog(
builder: (context) =>
ErrorDialog(
exception: exception,

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

@@ -2,18 +2,17 @@ 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/dialog/abstract/dialog.dart';
import 'package:reboot_launcher/src/dialog/abstract/dialog_button.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{
var showPassword = RxBool(false);
var oldUsername = _gameController.username.text;
var showPasswordTrailing = RxBool(oldUsername.isNotEmpty);
var oldPassword = _gameController.password.text;
var result = await showAppDialog<bool?>(
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,

View File

@@ -1,24 +1,20 @@
import 'dart:async';
import 'package:clipboard/clipboard.dart';
import 'package:dart_ipify/dart_ipify.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/hosting_controller.dart';
import 'package:reboot_launcher/src/dialog/abstract/dialog.dart';
import 'package:reboot_launcher/src/dialog/abstract/dialog_button.dart';
import 'package:reboot_launcher/src/dialog/abstract/info_bar.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';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:sync/semaphore.dart';
final Semaphore _publishingSemaphore = Semaphore();
extension ServerControllerDialog on BackendController {
Future<bool> toggleInteractive() async {
@@ -39,108 +35,108 @@ extension ServerControllerDialog on BackendController {
}
InfoBarEntry _handeEvent(ServerResult event) {
switch (event.type) {
log("[BACKEND] Handling event: $event");
switch (event.type) {
case ServerResultType.starting:
return showInfoBar(
return showRebootInfoBar(
translations.startingServer,
severity: InfoBarSeverity.info,
loading: true,
duration: null
);
case ServerResultType.startSuccess:
return showInfoBar(
return showRebootInfoBar(
type.value == ServerType.local ? translations.checkedServer : translations.startedServer,
severity: InfoBarSeverity.success
);
case ServerResultType.startError:
print(event.stackTrace);
return showInfoBar(
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 showInfoBar(
return showRebootInfoBar(
translations.stoppingServer,
severity: InfoBarSeverity.info,
loading: true,
duration: null
);
case ServerResultType.stopSuccess:
return showInfoBar(
return showRebootInfoBar(
translations.stoppedServer,
severity: InfoBarSeverity.success
);
case ServerResultType.stopError:
return showInfoBar(
return showRebootInfoBar(
translations.stopServerError(event.error ?? translations.unknownError),
severity: InfoBarSeverity.error,
duration: infoBarLongDuration
);
case ServerResultType.missingHostError:
return showInfoBar(
return showRebootInfoBar(
translations.missingHostNameError,
severity: InfoBarSeverity.error
);
case ServerResultType.missingPortError:
return showInfoBar(
return showRebootInfoBar(
translations.missingPortError,
severity: InfoBarSeverity.error
);
case ServerResultType.illegalPortError:
return showInfoBar(
return showRebootInfoBar(
translations.illegalPortError,
severity: InfoBarSeverity.error
);
case ServerResultType.freeingPort:
return showInfoBar(
return showRebootInfoBar(
translations.freeingPort,
loading: true,
duration: null
);
case ServerResultType.freePortSuccess:
return showInfoBar(
return showRebootInfoBar(
translations.freedPort,
severity: InfoBarSeverity.success,
duration: infoBarShortDuration
);
case ServerResultType.freePortError:
return showInfoBar(
return showRebootInfoBar(
translations.freePortError(event.error ?? translations.unknownError),
severity: InfoBarSeverity.error,
duration: infoBarLongDuration
);
case ServerResultType.pingingRemote:
return showInfoBar(
translations.pingingRemoteServer,
return showRebootInfoBar(
translations.pingingServer(ServerType.remote.name),
severity: InfoBarSeverity.info,
loading: true,
duration: null
);
case ServerResultType.pingingLocal:
return showInfoBar(
translations.pingingLocalServer(type.value.name),
return showRebootInfoBar(
translations.pingingServer(type.value.name),
severity: InfoBarSeverity.info,
loading: true,
duration: null
);
case ServerResultType.pingError:
return showInfoBar(
return showRebootInfoBar(
translations.pingError(type.value.name),
severity: InfoBarSeverity.error
);
case ServerResultType.processError:
return showRebootInfoBar(
translations.backendProcessError,
severity: InfoBarSeverity.error
);
}
}
void joinLocalHost() {
gameServerAddress.text = kDefaultGameServerHost;
gameServerOwner.value = null;
}
Future<void> joinServer(String uuid, Map<String, dynamic> entry) async {
final id = entry["id"];
if(uuid == id) {
showInfoBar(
Future<void> joinServerInteractive(String uuid, FortniteServer server) async {
if(!kDebugMode && uuid == server.id) {
showRebootInfoBar(
translations.joinSelfServer,
duration: infoBarLongDuration,
severity: InfoBarSeverity.error
@@ -148,18 +144,29 @@ extension ServerControllerDialog on BackendController {
return;
}
final hashedPassword = entry["password"];
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 = entry["author"];
final encryptedIp = entry["ip"];
final author = server.author;
final encryptedIp = server.ip;
if(!hasPassword) {
final valid = await _isServerValid(encryptedIp);
if(!valid) {
return;
}
_onSuccess(embedded, encryptedIp, author);
_onSuccess(gameController, embedded, encryptedIp, author, version);
return;
}
@@ -169,7 +176,7 @@ extension ServerControllerDialog on BackendController {
}
if(!checkPassword(confirmPassword, hashedPassword)) {
showInfoBar(
showRebootInfoBar(
translations.wrongServerPassword,
duration: infoBarLongDuration,
severity: InfoBarSeverity.error
@@ -183,7 +190,7 @@ extension ServerControllerDialog on BackendController {
return;
}
_onSuccess(embedded, decryptedIp, author);
_onSuccess(gameController, embedded, decryptedIp, author, version);
}
Future<bool> _isServerValid(String address) async {
@@ -192,7 +199,7 @@ extension ServerControllerDialog on BackendController {
return true;
}
showInfoBar(
showRebootInfoBar(
translations.offlineServer,
duration: infoBarLongDuration,
severity: InfoBarSeverity.error
@@ -204,7 +211,7 @@ extension ServerControllerDialog on BackendController {
final confirmPasswordController = TextEditingController();
final showPassword = RxBool(false);
final showPasswordTrailing = RxBool(false);
return await showAppDialog<String?>(
return await showRebootDialog<String?>(
builder: (context) => FormDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
@@ -253,80 +260,18 @@ extension ServerControllerDialog on BackendController {
);
}
void _onSuccess(bool embedded, String decryptedIp, String author) {
void _onSuccess(GameController controller, bool embedded, String decryptedIp, String author, FortniteVersion version) {
if(embedded) {
gameServerAddress.text = decryptedIp;
gameServerOwner.value = author;
pageIndex.value = 0;
pageIndex.value = RebootPageType.play.index;
}else {
FlutterClipboard.controlC(decryptedIp);
}
WidgetsBinding.instance.addPostFrameCallback((_) => showInfoBar(
controller.selectedVersion = version;
WidgetsBinding.instance.addPostFrameCallback((_) => showRebootInfoBar(
embedded ? translations.joinedServer(author) : translations.copiedIp,
duration: infoBarLongDuration,
severity: InfoBarSeverity.success
));
}
}
extension HostingControllerExtension on HostingController {
Future<void> publishServer(String author, String version) async {
try {
_publishingSemaphore.acquire();
if(published.value) {
return;
}
final passwordText = password.text;
final hasPassword = passwordText.isNotEmpty;
var ip = await Ipify.ipv4();
if(hasPassword) {
ip = aes256Encrypt(ip, passwordText);
}
final supabase = Supabase.instance.client;
final hosts = supabase.from("hosting");
final payload = {
'name': name.text,
'description': description.text,
'author': author,
'ip': ip,
'version': version,
'password': hasPassword ? hashPassword(passwordText) : null,
'timestamp': DateTime.now().toIso8601String(),
'discoverable': discoverable.value
};
if(published()) {
await hosts.update(payload).eq("id", uuid);
}else {
payload["id"] = uuid;
await hosts.insert(payload);
}
published.value = true;
}catch(error) {
published.value = false;
}finally {
_publishingSemaphore.release();
}
}
Future<void> discardServer() async {
try {
_publishingSemaphore.acquire();
if(!published.value) {
return;
}
final supabase = Supabase.instance.client;
await supabase.from("hosting")
.delete()
.match({'id': uuid});
published.value = false;
}catch(_) {
published.value = true;
}finally {
_publishingSemaphore.release();
}
}
}

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;
}
}
}

View File

@@ -6,7 +6,9 @@ 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/dialog/abstract/info_bar.dart';
import 'package:reboot_launcher/src/messenger/abstract/info_bar.dart';
import 'package:reboot_launcher/src/messenger/abstract/overlay.dart';
import 'package:reboot_launcher/src/messenger/implementation/data.dart';
import 'package:reboot_launcher/src/page/abstract/page.dart';
import 'package:reboot_launcher/src/page/abstract/page_type.dart';
import 'package:reboot_launcher/src/util/keyboard.dart';
@@ -16,7 +18,10 @@ import 'package:reboot_launcher/src/widget/server_type_selector.dart';
import 'package:reboot_launcher/src/widget/setting_tile.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../dialog/implementation/data.dart';
final GlobalKey<OverlayTargetState> backendTypeOverlayTargetKey = GlobalKey();
final GlobalKey<OverlayTargetState> backendGameServerAddressOverlayTargetKey = GlobalKey();
final GlobalKey<OverlayTargetState> backendUnrealEngineOverlayTargetKey = GlobalKey();
final GlobalKey<OverlayTargetState> backendDetachedOverlayTargetKey = GlobalKey();
class BackendPage extends RebootPage {
const BackendPage({Key? key}) : super(key: key);
@@ -42,7 +47,7 @@ class _BackendPageState extends RebootPageState<BackendPage> {
final BackendController _backendController = Get.find<BackendController>();
InfoBarEntry? _infoBarEntry;
@override
void initState() {
ServicesBinding.instance.keyboard.addHandler((keyEvent) {
@@ -60,7 +65,7 @@ class _BackendPageState extends RebootPageState<BackendPage> {
});
super.initState();
}
@override
List<Widget> get settings => [
_type,
@@ -84,10 +89,13 @@ class _BackendPageState extends RebootPageState<BackendPage> {
),
title: Text(translations.matchmakerConfigurationAddressName),
subtitle: Text(translations.matchmakerConfigurationAddressDescription),
content: TextFormBox(
placeholder: translations.matchmakerConfigurationAddressName,
controller: _backendController.gameServerAddress,
focusNode: _backendController.gameServerAddressFocusNode
content: OverlayTarget(
key: backendGameServerAddressOverlayTargetKey,
child: TextFormBox(
placeholder: translations.matchmakerConfigurationAddressName,
controller: _backendController.gameServerAddress,
focusNode: _backendController.gameServerAddressFocusNode
),
)
);
});
@@ -152,15 +160,18 @@ class _BackendPageState extends RebootPageState<BackendPage> {
const SizedBox(
width: 16.0
),
ToggleSwitch(
checked: _backendController.detached(),
onChanged: (value) => _backendController.detached.value = value
OverlayTarget(
key: backendDetachedOverlayTargetKey,
child: ToggleSwitch(
checked: _backendController.detached(),
onChanged: (value) => _backendController.detached.value = value
),
),
],
)
);
});
});
Widget get _unrealEngineConsoleKey => Obx(() {
if(_backendController.type.value != ServerType.embedded) {
return const SizedBox.shrink();
@@ -173,14 +184,18 @@ class _BackendPageState extends RebootPageState<BackendPage> {
title: Text(translations.settingsClientConsoleKeyName),
subtitle: Text(translations.settingsClientConsoleKeyDescription),
contentWidth: null,
content: Button(
onPressed: () {
_infoBarEntry = showInfoBar(
translations.clickKey,
loading: true
);
},
child: Text(_gameController.consoleKey.value.unrealEnginePrettyName ?? ""),
content: OverlayTarget(
key: backendUnrealEngineOverlayTargetKey,
child: Button(
onPressed: () {
_infoBarEntry = showRebootInfoBar(
translations.clickKey,
loading: true,
duration: null
);
},
child: Text(_gameController.consoleKey.value.unrealEnginePrettyName ?? ""),
),
)
);
});
@@ -221,7 +236,9 @@ class _BackendPageState extends RebootPageState<BackendPage> {
),
title: Text(translations.backendTypeName),
subtitle: Text(translations.backendTypeDescription),
content: const ServerTypeSelector()
content: ServerTypeSelector(
overlayKey: backendTypeOverlayTargetKey
)
);
@override

View File

@@ -0,0 +1,359 @@
import 'dart:async';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:fluentui_system_icons/fluentui_system_icons.dart' as fluentUiIcons;
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/controller/hosting_controller.dart';
import 'package:reboot_launcher/src/messenger/abstract/dialog.dart';
import 'package:reboot_launcher/src/messenger/implementation/server.dart';
import 'package:reboot_launcher/src/page/abstract/page.dart';
import 'package:reboot_launcher/src/page/abstract/page_type.dart';
import 'package:reboot_launcher/src/util/translations.dart';
import 'package:reboot_launcher/src/widget/setting_tile.dart';
class BrowsePage extends RebootPage {
const BrowsePage({Key? key}) : super(key: key);
@override
String get name => translations.browserName;
@override
RebootPageType get type => RebootPageType.browser;
@override
String get iconAsset => "assets/images/server_browser.png";
@override
bool hasButton(String? pageName) => false;
@override
RebootPageState<BrowsePage> createState() => _BrowsePageState();
}
class _BrowsePageState extends RebootPageState<BrowsePage> {
final GameController _gameController = Get.find<GameController>();
final HostingController _hostingController = Get.find<HostingController>();
final BackendController _backendController = Get.find<BackendController>();
final TextEditingController _filterController = TextEditingController();
final StreamController<String> _filterControllerStream = StreamController.broadcast();
final Rx<_Filter> _filter = Rx(_Filter.all);
final Rx<_Sort> _sort = Rx(_Sort.timeDescending);
@override
Widget build(BuildContext context) {
super.build(context);
return Obx(() {
final data = _hostingController.servers.value
?.where((entry) => (kDebugMode || entry.id != _hostingController.uuid) && entry.discoverable)
.toSet();
if(data == null || data.isEmpty == true) {
return _noServers;
}
return _buildPageBody(data);
});
}
Widget get _noServers => Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
translations.noServersAvailableTitle,
style: FluentTheme.of(context).typography.titleLarge,
),
Text(
translations.noServersAvailableSubtitle,
style: FluentTheme.of(context).typography.body
),
],
);
Widget _buildPageBody(Set<FortniteServer> data) => StreamBuilder(
stream: _filterControllerStream.stream,
builder: (context, filterSnapshot) {
final items = data.where((entry) => _isValidItem(entry, filterSnapshot.data)).toSet();
return Column(
children: [
_searchBar,
const SizedBox(
height: 24,
),
Row(
children: [
_buildFilter(context),
const SizedBox(
width: 16.0
),
_buildSort(context),
],
),
const SizedBox(
height: 24,
),
Expanded(
child: _buildPopulatedListBody(items)
),
],
);
}
);
Widget _buildSort(BuildContext context) => Row(
children: [
Icon(
fluentUiIcons.FluentIcons.arrow_sort_24_regular,
color: FluentTheme.of(context).resources.textFillColorDisabled
),
const SizedBox(width: 4.0),
Text(
"Sort by: ",
style: TextStyle(
color: FluentTheme.of(context).resources.textFillColorDisabled
),
),
const SizedBox(width: 4.0),
Obx(() => SizedBox(
width: 230,
child: DropDownButton(
onOpen: () => inDialog = true,
onClose: () => inDialog = false,
leading: Text(
_sort.value.translatedName,
textAlign: TextAlign.start
),
title: const Spacer(),
items: _Sort.values.map((entry) => MenuFlyoutItem(
text: Text(entry.translatedName),
onPressed: () => _sort.value = entry
)).toList()
),
))
],
);
Row _buildFilter(BuildContext context) {
return Row(
children: [
Icon(
fluentUiIcons.FluentIcons.filter_24_regular,
color: FluentTheme.of(context).resources.textFillColorDisabled
),
const SizedBox(width: 4.0),
Text(
"Filter by: ",
style: TextStyle(
color: FluentTheme.of(context).resources.textFillColorDisabled
),
),
const SizedBox(width: 4.0),
Obx(() => SizedBox(
width: 125,
child: DropDownButton(
onOpen: () => inDialog = true,
onClose: () => inDialog = false,
leading: Text(
_filter.value.translatedName,
textAlign: TextAlign.start
),
title: const Spacer(),
items: _Filter.values.map((entry) => MenuFlyoutItem(
text: Text(entry.translatedName),
onPressed: () => _filter.value = entry
)).toList()
),
))
],
);
}
Widget _buildPopulatedListBody(Set<FortniteServer> items) => Obx(() {
final filter = _filter.value;
final sorted = items.where((element) {
switch(filter) {
case _Filter.all:
return true;
case _Filter.accessible:
return element.password == null;
case _Filter.playable:
return _gameController.getVersionByName(element.version) != null;
}
}).toList();
final sort = _sort.value;
sorted.sort((first, second) {
switch(sort) {
case _Sort.timeAscending:
return first.timestamp.compareTo(second.timestamp);
case _Sort.timeDescending:
return second.timestamp.compareTo(first.timestamp);
case _Sort.nameAscending:
return first.name.compareTo(second.name);
case _Sort.nameDescending:
return second.name.compareTo(first.name);
}
});
if(sorted.isEmpty) {
return _noServersByQuery;
}
return ListView.builder(
itemCount: sorted.length,
itemBuilder: (context, index) {
final entry = sorted.elementAt(index);
final hasPassword = entry.password != null;
return SettingTile(
icon: Icon(
hasPassword ? FluentIcons.lock : FluentIcons.globe
),
title: Text("${_formatName(entry)}${entry.author}"),
subtitle: Text("${_formatDescription(entry)}${_formatVersion(entry)}"),
content: Button(
onPressed: () => _backendController.joinServerInteractive(_hostingController.uuid, entry),
child: Text(_backendController.type.value == ServerType.embedded ? translations.joinServer : translations.copyIp),
)
);
}
);
});
Widget get _noServersByQuery => Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
translations.noServersAvailableByQueryTitle,
style: FluentTheme.of(context).typography.titleLarge,
),
Text(
translations.noServersAvailableByQuerySubtitle,
style: FluentTheme.of(context).typography.body
),
],
);
bool _isValidItem(FortniteServer entry, String? filter) =>
filter == null || filter.isEmpty || _filterServer(entry, filter);
bool _filterServer(FortniteServer element, String filter) {
filter = filter.toLowerCase();
final uri = Uri.tryParse(filter);
if(uri != null && uri.host.isNotEmpty && element.id.toLowerCase().contains(uri.host.toLowerCase())) {
return true;
}
return element.id.toLowerCase().contains(filter.toLowerCase())
|| element.name.toLowerCase().contains(filter)
|| element.author.toLowerCase().contains(filter)
|| element.description.toLowerCase().contains(filter);
}
Widget get _searchBar => Align(
alignment: Alignment.centerLeft,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 350
),
child: TextBox(
placeholder: translations.findServer,
controller: _filterController,
autofocus: true,
onChanged: (value) => _filterControllerStream.add(value),
suffix: _searchBarIcon,
),
),
);
Widget get _searchBarIcon => Button(
onPressed: _filterController.text.isEmpty ? null : () {
_filterController.clear();
_filterControllerStream.add("");
},
style: ButtonStyle(
backgroundColor: ButtonState.all(Colors.transparent),
shape: ButtonState.all(Border())
),
child: _searchBarIconData
);
Widget get _searchBarIconData {
final color = FluentTheme.of(context).resources.textFillColorPrimary;
if (_filterController.text.isNotEmpty) {
return Icon(
FluentIcons.clear,
size: 8.0,
color: color
);
}
return Transform.flip(
flipX: true,
child: Icon(
FluentIcons.search,
size: 12.0,
color: color
),
);
}
String _formatName(FortniteServer server) {
final result = server.name;
return result.isEmpty ? translations.defaultServerName : result;
}
String _formatDescription(FortniteServer server) {
final result = server.description;
return result.isEmpty ? translations.defaultServerDescription : result;
}
String _formatVersion(FortniteServer server) => "Fortnite ${server.version.toString()}";
@override
Widget? get button => null;
@override
List<Widget> get settings => [];
}
enum _Filter {
all,
accessible,
playable;
String get translatedName {
switch(this) {
case _Filter.all:
return translations.all;
case _Filter.accessible:
return translations.accessible;
case _Filter.playable:
return translations.playable;
}
}
}
enum _Sort {
timeAscending,
timeDescending,
nameAscending,
nameDescending;
String get translatedName {
switch(this) {
case _Sort.timeAscending:
return translations.timeAscending;
case _Sort.timeDescending:
return translations.timeDescending;
case _Sort.nameAscending:
return translations.nameAscending;
case _Sort.nameDescending:
return translations.nameDescending;
}
}
}

View File

@@ -12,15 +12,14 @@ import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/src/controller/backend_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/controller/update_controller.dart';
import 'package:reboot_launcher/src/dialog/abstract/dialog.dart';
import 'package:reboot_launcher/src/dialog/abstract/info_bar.dart';
import 'package:reboot_launcher/src/dialog/implementation/dll.dart';
import 'package:reboot_launcher/src/dialog/implementation/server.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/messenger/abstract/overlay.dart';
import 'package:reboot_launcher/src/messenger/implementation/dll.dart';
import 'package:reboot_launcher/src/messenger/implementation/server.dart';
import 'package:reboot_launcher/src/page/abstract/page.dart';
import 'package:reboot_launcher/src/page/abstract/page_suggestion.dart';
import 'package:reboot_launcher/src/page/pages.dart';
import 'package:reboot_launcher/src/util/dll.dart';
import 'package:reboot_launcher/src/util/matchmaker.dart';
import 'package:reboot_launcher/src/util/os.dart';
import 'package:reboot_launcher/src/util/translations.dart';
@@ -29,9 +28,12 @@ import 'package:reboot_launcher/src/widget/profile_tile.dart';
import 'package:reboot_launcher/src/widget/title_bar.dart';
import 'package:window_manager/window_manager.dart';
import 'info_page.dart';
final GlobalKey<OverlayTargetState> profileOverlayKey = GlobalKey();
class HomePage extends StatefulWidget {
static const double kDefaultPadding = 12.0;
static const double kTitleBarHeight = 32;
const HomePage({Key? key}) : super(key: key);
@override
@@ -39,16 +41,14 @@ class HomePage extends StatefulWidget {
}
class _HomePageState extends State<HomePage> with WindowListener, AutomaticKeepAliveClientMixin {
static const double _kDefaultPadding = 12.0;
final BackendController _backendController = Get.find<BackendController>();
final HostingController _hostingController = Get.find<HostingController>();
final SettingsController _settingsController = Get.find<SettingsController>();
final UpdateController _updateController = Get.find<UpdateController>();
final GlobalKey _searchKey = GlobalKey();
final FocusNode _searchFocusNode = FocusNode();
final TextEditingController _searchController = TextEditingController();
final RxBool _focused = RxBool(true);
final PageController _pageController = PageController(keepPage: true, initialPage: pageIndex.value);
@override
bool get wantKeepAlive => true;
@@ -56,7 +56,9 @@ class _HomePageState extends State<HomePage> with WindowListener, AutomaticKeepA
@override
void initState() {
super.initState();
windowManager.setPreventClose(true);
windowManager.addListener(this);
_syncPageViewWithNavigator();
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkUpdates();
_initAppLink();
@@ -64,6 +66,18 @@ class _HomePageState extends State<HomePage> with WindowListener, AutomaticKeepA
});
}
void _syncPageViewWithNavigator() {
var lastPage = pageIndex.value;
pageIndex.listen((index) {
if(index == lastPage) {
return;
}
lastPage = index;
_pageController.jumpToPage(index);
});
}
void _initAppLink() async {
final appLinks = AppLinks();
final initialUrl = await appLinks.getInitialLink();
@@ -78,9 +92,9 @@ class _HomePageState extends State<HomePage> with WindowListener, AutomaticKeepA
final uuid = uri.host;
final server = _hostingController.findServerById(uuid);
if(server != null) {
_backendController.joinServer(_hostingController.uuid, server);
_backendController.joinServerInteractive(_hostingController.uuid, server);
}else {
showInfoBar(
showRebootInfoBar(
translations.noServerFound,
duration: infoBarLongDuration,
severity: InfoBarSeverity.error
@@ -100,10 +114,9 @@ class _HomePageState extends State<HomePage> with WindowListener, AutomaticKeepA
return;
}
var oldOwner = _backendController.gameServerOwner.value;
_backendController.joinLocalHost();
WidgetsBinding.instance.addPostFrameCallback((_) => showInfoBar(
oldOwner == null ? translations.serverNoLongerAvailableUnnamed : translations.serverNoLongerAvailable(oldOwner),
_backendController.joinLocalhost();
WidgetsBinding.instance.addPostFrameCallback((_) => showRebootInfoBar(
translations.serverNoLongerAvailableUnnamed,
severity: InfoBarSeverity.warning,
duration: infoBarLongDuration
));
@@ -114,27 +127,34 @@ class _HomePageState extends State<HomePage> with WindowListener, AutomaticKeepA
}
void _checkUpdates() {
_updateController.notifyLauncherUpdate();
_settingsController.notifyLauncherUpdate();
if(!dllsDirectory.existsSync()) {
dllsDirectory.createSync(recursive: true);
}
for(final injectable in InjectableDll.values) {
downloadCriticalDllInteractive(
injectable.path,
silent: true
);
final (file, custom) = _settingsController.getInjectableData(injectable);
if(!custom) {
_settingsController.downloadCriticalDllInteractive(
file.path,
silent: true
);
}
}
watchDlls().listen((filePath) => showDllDeletedDialog(() {
downloadCriticalDllInteractive(filePath);
_settingsController.downloadCriticalDllInteractive(filePath);
}));
}
@override
void onWindowClose() {
exit(0); // Force closing
void onWindowClose() async {
try {
await _hostingController.discardServer();
}finally {
exit(0); // Force closing
}
}
@override
@@ -153,7 +173,7 @@ class _HomePageState extends State<HomePage> with WindowListener, AutomaticKeepA
@override
void onWindowBlur() {
_focused.value = false;
_focused.value = !_focused.value;
}
@override
@@ -218,137 +238,364 @@ class _HomePageState extends State<HomePage> with WindowListener, AutomaticKeepA
_focused.value = true;
}
@override
void onWindowEvent(String eventName) {
if(eventName != "move") {
WidgetsBinding.instance.addPostFrameCallback((_) => log("[WINDOW] Event: $eventName ${_focused.value}"));
}
}
@override
Widget build(BuildContext context) {
super.build(context);
_settingsController.language.value;
loadTranslations(context);
// InfoPage.initInfoTiles();
return Obx(() {
return NavigationPaneTheme(
data: NavigationPaneThemeData(
backgroundColor: FluentTheme.of(context).micaBackgroundColor.withOpacity(0.93),
),
child: NavigationView(
paneBodyBuilder: (pane, body) => _PaneBody(
padding: _kDefaultPadding,
controller: pagesController,
body: body
),
appBar: NavigationAppBar(
height: 32,
title: _draggableArea,
actions: WindowTitleBar(focused: _focused()),
leading: _backButton,
automaticallyImplyLeading: false,
),
pane: NavigationPane(
selected: pageIndex.value,
onChanged: (index) {
final lastPageIndex = pageIndex.value;
if(lastPageIndex != index) {
pageIndex.value = index;
}else if(pageStack.isNotEmpty) {
Navigator.of(pageKey.currentContext!).pop();
final element = pageStack.removeLast();
appStack.remove(element);
pagesController.add(null);
}
},
menuButton: const SizedBox(),
displayMode: PaneDisplayMode.open,
items: _items,
customPane: _CustomPane(_settingsController),
header: const ProfileWidget(),
autoSuggestBox: _autoSuggestBox,
indicator: const StickyNavigationIndicator(
duration: Duration(milliseconds: 500),
curve: Curves.easeOut,
indicatorSize: 3.25
return Container(
color: FluentTheme.of(context).micaBackgroundColor.withOpacity(0.93),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: HomePage.kTitleBarHeight,
child: Row(
children: [
_backButton,
Expanded(child: _draggableArea),
WindowTitleBar(focused: _focused())
],
)
),
Expanded(
child: Navigator(
key: appNavigatorKey,
onPopPage: (page, data) => false,
pages: [
MaterialPage(
child: Overlay(
key: appOverlayKey,
initialEntries: [
OverlayEntry(
maintainState: true,
builder: (context) => Row(
children: [
_buildLateralView(),
_buildBody()
],
)
)
],
),
)
),
contentShape: const RoundedRectangleBorder(),
onOpenSearch: () => _searchFocusNode.requestFocus(),
transitionBuilder: (child, animation) => child
)
],
)
)
],
),
);
});
}
Widget _buildBody() {
return Expanded(
child: Padding(
padding: EdgeInsets.only(
left: HomePage.kDefaultPadding,
right: HomePage.kDefaultPadding * 2,
top: HomePage.kDefaultPadding,
bottom: HomePage.kDefaultPadding * 2
),
child: Column(
children: [
Expanded(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 1000
),
child: Center(
child: Column(
children: [
_buildBodyHeader(),
const SizedBox(height: 24.0),
Expanded(
child: Stack(
fit: StackFit.loose,
children: [
_buildBodyContent(),
InfoBarArea(
key: infoBarAreaKey
)
],
)
),
],
),
),
),
)
],
)
),
);
}
Widget _buildBodyContent() => PageView.builder(
controller: _pageController,
itemBuilder: (context, index) => Navigator(
onPopPage: (page, data) => true,
observers: [
_NestedPageObserver(
onChanged: (routeName) {
if(routeName != null) {
pageIndex.refresh();
addSubPageToStack(routeName);
pagesController.add(null);
}
}
)
],
pages: [
MaterialPage(
child: KeyedSubtree(
key: getPageKeyByIndex(index),
child: pages[index]
)
)
],
),
itemCount: pages.length
);
Widget _buildBodyHeader() {
final themeMode = _settingsController.themeMode.value;
final inactiveColor = themeMode == ThemeMode.dark
|| (themeMode == ThemeMode.system && isDarkMode) ? Colors.grey[60] : Colors.grey[100];
return Align(
alignment: Alignment.centerLeft,
child: StreamBuilder(
stream: pagesController.stream,
builder: (context, _) {
final elements = <TextSpan>[];
elements.add(_buildBodyHeaderRootPage(inactiveColor));
for(var i = pageStack.length - 1; i >= 0; i--) {
var innerPage = pageStack.elementAt(i);
innerPage = innerPage.substring(innerPage.indexOf("_") + 1);
elements.add(_buildBodyHeaderPageSeparator(inactiveColor));
elements.add(_buildBodyHeaderNestedPage(innerPage, i, inactiveColor));
}
return Text.rich(
TextSpan(
children: elements
),
style: TextStyle(
fontSize: 32.0,
fontWeight: FontWeight.w600
),
);
}
),
);
}
TextSpan _buildBodyHeaderRootPage(Color inactiveColor) => TextSpan(
text: pages[pageIndex.value].name,
recognizer: pageStack.isNotEmpty ? (TapGestureRecognizer()..onTap = () {
if(inDialog) {
return;
}
for(var i = 0; i < pageStack.length; i++) {
Navigator.of(pageKey.currentContext!).pop();
final element = pageStack.removeLast();
appStack.remove(element);
}
pagesController.add(null);
}) : null,
style: TextStyle(
color: pageStack.isNotEmpty ? inactiveColor : null
)
);
TextSpan _buildBodyHeaderPageSeparator(Color inactiveColor) => TextSpan(
text: " > ",
style: TextStyle(
color: inactiveColor
)
);
TextSpan _buildBodyHeaderNestedPage(String nestedPageName, int nestedPageIndex, Color inactiveColor) => TextSpan(
text: nestedPageName,
recognizer: nestedPageIndex == pageStack.length - 1 ? null : (TapGestureRecognizer()..onTap = () {
if(inDialog) {
return;
}
for(var j = 0; j < nestedPageIndex - 1; j++) {
Navigator.of(pageKey.currentContext!).pop();
final element = pageStack.removeLast();
appStack.remove(element);
}
pagesController.add(null);
}),
style: TextStyle(
color: nestedPageIndex == pageStack.length - 1 ? null : inactiveColor
)
);
Widget _buildLateralView() => SizedBox(
width: 310,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ProfileWidget(
overlayKey: profileOverlayKey
),
_autoSuggestBox,
const SizedBox(height: 12.0),
_buildNavigationTrail()
],
),
);
Widget _buildNavigationTrail() => Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0
),
child: Scrollbar(
child: ListView.separated(
primary: true,
itemCount: pages.length,
separatorBuilder: (context, index) => const SizedBox(
height: 4.0
),
itemBuilder: (context, index) => _buildNavigationItem(pages[index]),
),
),
)
);
Widget _buildNavigationItem(RebootPage page) {
final index = page.type.index;
return OverlayTarget(
key: getOverlayTargetKeyByPage(index),
child: HoverButton(
onPressed: () {
final lastPageIndex = pageIndex.value;
if(lastPageIndex != index) {
pageIndex.value = index;
}else if(pageStack.isNotEmpty) {
Navigator.of(pageKey.currentContext!).pop();
final element = pageStack.removeLast();
appStack.remove(element);
pagesController.add(null);
}
},
builder: (context, states) => Obx(() => Container(
height: 36,
decoration: BoxDecoration(
color: ButtonThemeData.uncheckedInputColor(
FluentTheme.of(context),
pageIndex.value == index ? {ButtonStates.hovering} : states,
transparentWhenNone: true,
),
borderRadius: BorderRadius.all(Radius.circular(6.0))
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0
),
child: Row(
children: [
SizedBox.square(
dimension: 24,
child: Image.asset(page.iconAsset)
),
const SizedBox(width: 12.0),
Text(page.name)
],
),
),
)),
),
);
}
Widget get _backButton => StreamBuilder(
stream: pagesController.stream,
builder: (context, _) => Button(
style: ButtonStyle(
padding: ButtonState.all(const EdgeInsets.only(top: 6.0)),
backgroundColor: ButtonState.all(Colors.transparent),
shape: ButtonState.all(Border())
),
onPressed: appStack.isEmpty && !inDialog ? null : () {
if(inDialog) {
Navigator.of(appKey.currentContext!).pop();
}else {
final lastPage = appStack.removeLast();
pageStack.remove(lastPage);
if (lastPage is int) {
hitBack = true;
pageIndex.value = lastPage;
} else {
Navigator.of(pageKey.currentContext!).pop();
}
style: ButtonStyle(
padding: ButtonState.all(const EdgeInsets.symmetric(
vertical: 12.0,
horizontal: 16.0
)),
backgroundColor: ButtonState.all(Colors.transparent),
shape: ButtonState.all(Border())
),
onPressed: appStack.isEmpty && !inDialog ? null : () {
if(inDialog) {
Navigator.of(appNavigatorKey.currentContext!).pop();
}else {
final lastPage = appStack.removeLast();
pageStack.remove(lastPage);
if (lastPage is int) {
hitBack = true;
pageIndex.value = lastPage;
} else {
Navigator.of(pageKey.currentContext!).pop();
}
pagesController.add(null);
},
child: const Icon(FluentIcons.back, size: 12.0),
)
}
pagesController.add(null);
},
child: const Icon(FluentIcons.back, size: 12.0),
)
);
GestureDetector get _draggableArea => GestureDetector(
onDoubleTap: appWindow.maximizeOrRestore,
onHorizontalDragStart: (_) => appWindow.startDragging(),
onVerticalDragStart: (_) => appWindow.startDragging()
onHorizontalDragStart: (_) => windowManager.startDragging(),
onVerticalDragStart: (_) => windowManager.startDragging()
);
Widget get _autoSuggestBox => Obx(() {
final firstRun = _settingsController.firstRun.value;
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0
Widget get _autoSuggestBox => Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0
),
child: AutoSuggestBox<PageSuggestion>(
key: _searchKey,
controller: _searchController,
placeholder: translations.find,
focusNode: _searchFocusNode,
selectionHeightStyle: BoxHeightStyle.max,
itemBuilder: (context, item) => ListTile(
onPressed: () {
pageIndex.value = item.value.pageIndex;
_searchController.clear();
_searchFocusNode.unfocus();
},
leading: item.child,
title: Text(
item.value.name,
overflow: TextOverflow.clip,
maxLines: 1
)
),
child: AutoSuggestBox<PageSuggestion>(
key: _searchKey,
controller: _searchController,
enabled: !firstRun,
placeholder: translations.find,
focusNode: _searchFocusNode,
selectionHeightStyle: BoxHeightStyle.max,
itemBuilder: (context, item) => ListTile(
onPressed: () {
pageIndex.value = item.value.pageIndex;
_searchController.clear();
_searchFocusNode.unfocus();
},
leading: item.child,
title: Text(
item.value.name,
overflow: TextOverflow.clip,
maxLines: 1
)
),
items: _suggestedItems,
autofocus: true,
trailingIcon: IgnorePointer(
child: IconButton(
onPressed: () {},
icon: Transform.flip(
flipX: true,
child: const Icon(FluentIcons.search)
),
)
),
)
);
});
items: _suggestedItems,
autofocus: true,
trailingIcon: IgnorePointer(
child: IconButton(
onPressed: () {},
icon: Transform.flip(
flipX: true,
child: const Icon(FluentIcons.search)
),
)
),
)
);
List<AutoSuggestBoxItem<PageSuggestion>> get _suggestedItems => pages.mapMany((page) {
final pageIcon = SizedBox.square(
@@ -367,282 +614,6 @@ class _HomePageState extends State<HomePage> with WindowListener, AutomaticKeepA
));
return results;
}).toList();
List<NavigationPaneItem> get _items => pages.map((page) => _createItem(page)).toList();
NavigationPaneItem _createItem(RebootPage page) => PaneItem(
title: Text(page.name),
icon: SizedBox.square(
dimension: 24,
child: Image.asset(page.iconAsset)
),
body: page
);
}
class _PaneBody extends StatefulWidget {
const _PaneBody({
required this.padding,
required this.controller,
required this.body
});
final double padding;
final StreamController<void> controller;
final Widget? body;
@override
State<_PaneBody> createState() => _PaneBodyState();
}
class _PaneBodyState extends State<_PaneBody> with AutomaticKeepAliveClientMixin {
final SettingsController _settingsController = Get.find<SettingsController>();
final PageController _pageController = PageController(keepPage: true, initialPage: pageIndex.value);
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
var lastPage = pageIndex.value;
pageIndex.listen((index) {
if(index == lastPage) {
return;
}
lastPage = index;
_pageController.jumpToPage(index);
});
}
@override
Widget build(BuildContext context) {
super.build(context);
final themeMode = _settingsController.themeMode.value;
final inactiveColor = themeMode == ThemeMode.dark
|| (themeMode == ThemeMode.system && isDarkMode) ? Colors.grey[60] : Colors.grey[100];
return Padding(
padding: EdgeInsets.only(
left: widget.padding,
right: widget.padding * 2,
top: widget.padding,
bottom: widget.padding * 2
),
child: Column(
children: [
Expanded(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 1000
),
child: Center(
child: Column(
children: [
Align(
alignment: Alignment.centerLeft,
child: StreamBuilder(
stream: widget.controller.stream,
builder: (context, _) {
final elements = <TextSpan>[];
elements.add(TextSpan(
text: pages[pageIndex.value].name,
recognizer: pageStack.isNotEmpty ? (TapGestureRecognizer()..onTap = () {
if(inDialog) {
return;
}
for(var i = 0; i < pageStack.length; i++) {
Navigator.of(pageKey.currentContext!).pop();
final element = pageStack.removeLast();
appStack.remove(element);
}
widget.controller.add(null);
}) : null,
style: TextStyle(
color: pageStack.isNotEmpty ? inactiveColor : null
)
));
for(var i = pageStack.length - 1; i >= 0; i--) {
var innerPage = pageStack.elementAt(i);
innerPage = innerPage.substring(innerPage.indexOf("_") + 1);
elements.add(TextSpan(
text: " > ",
style: TextStyle(
color: inactiveColor
)
));
elements.add(TextSpan(
text: innerPage,
recognizer: i == pageStack.length - 1 ? null : (TapGestureRecognizer()..onTap = () {
if(inDialog) {
return;
}
for(var j = 0; j < i - 1; j++) {
Navigator.of(pageKey.currentContext!).pop();
final element = pageStack.removeLast();
appStack.remove(element);
}
widget.controller.add(null);
}),
style: TextStyle(
color: i == pageStack.length - 1 ? null : inactiveColor
)
));
}
return Text.rich(
TextSpan(
children: elements
),
style: TextStyle(
fontSize: 32.0,
fontWeight: FontWeight.w600
),
);
}
),
),
const SizedBox(height: 24.0),
Expanded(
child: Stack(
fit: StackFit.loose,
children: [
PageView.builder(
controller: _pageController,
itemBuilder: (context, index) => Navigator(
onPopPage: (page, data) => true,
observers: [
_NestedPageObserver(
onChanged: (routeName) {
if(routeName != null) {
pageIndex.refresh();
addSubPageToStack(routeName);
widget.controller.add(null);
}
}
)
],
pages: [
MaterialPage(
child: KeyedSubtree(
key: getPageKeyByIndex(index),
child: widget.body ?? const SizedBox.shrink()
)
)
],
),
itemCount: pages.length
),
InfoBarArea(
key: infoBarAreaKey
)
],
)
),
],
),
),
),
)
],
)
);
}
}
class _CustomPane extends NavigationPaneWidget {
final SettingsController settingsController;
_CustomPane(this.settingsController);
@override
Widget build(BuildContext context, NavigationPaneWidgetData data) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
data.appBar,
Expanded(
child: Navigator(
key: appKey,
onPopPage: (page, data) => false,
pages: [
MaterialPage(
child: Row(
children: [
SizedBox(
width: 310,
child: Column(
mainAxisSize: MainAxisSize.max,
children: [
data.pane.header ?? const SizedBox.shrink(),
data.pane.autoSuggestBox ?? const SizedBox.shrink(),
const SizedBox(height: 12.0),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0
),
child: Scrollbar(
controller: data.scrollController,
child: ListView.separated(
controller: data.scrollController,
itemCount: data.pane.items.length,
separatorBuilder: (context, index) => const SizedBox(
height: 4.0
),
itemBuilder: (context, index) {
final item = data.pane.items[index] as PaneItem;
return Obx(() {
final firstRun = settingsController.firstRun.value;
return HoverButton(
onPressed: firstRun ? null : () => data.pane.onChanged?.call(index),
builder: (context, states) => Container(
height: 36,
decoration: BoxDecoration(
color: ButtonThemeData.uncheckedInputColor(
FluentTheme.of(context),
item == data.pane.selectedItem ? {ButtonStates.hovering} : states,
transparentWhenNone: true,
),
borderRadius: BorderRadius.all(Radius.circular(6.0))
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0
),
child: Row(
children: [
data.pane.indicator ?? const SizedBox.shrink(),
item.icon,
const SizedBox(width: 12.0),
item.title ?? const SizedBox.shrink()
],
),
),
),
);
});
},
),
),
),
)
],
),
),
Expanded(
child: data.content
)
],
)
)
],
),
)
],
);
}
class _NestedPageObserver extends NavigatorObserver {

View File

@@ -10,11 +10,10 @@ import 'package:reboot_launcher/main.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/controller/update_controller.dart';
import 'package:reboot_launcher/src/dialog/abstract/dialog.dart';
import 'package:reboot_launcher/src/dialog/abstract/info_bar.dart';
import 'package:reboot_launcher/src/dialog/implementation/data.dart';
import 'package:reboot_launcher/src/dialog/implementation/server.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/messenger/abstract/overlay.dart';
import 'package:reboot_launcher/src/messenger/implementation/data.dart';
import 'package:reboot_launcher/src/page/abstract/page.dart';
import 'package:reboot_launcher/src/page/abstract/page_type.dart';
import 'package:reboot_launcher/src/util/translations.dart';
@@ -23,7 +22,13 @@ import 'package:reboot_launcher/src/widget/game_start_button.dart';
import 'package:reboot_launcher/src/widget/setting_tile.dart';
import 'package:reboot_launcher/src/widget/version_selector_tile.dart';
import '../../util/checks.dart';
final GlobalKey<OverlayTargetState> hostVersionOverlayTargetKey = GlobalKey();
final GlobalKey<OverlayTargetState> hostInfoOverlayTargetKey = GlobalKey();
final GlobalKey<OverlayTargetState> hostInfoNameOverlayTargetKey = GlobalKey();
final GlobalKey<OverlayTargetState> hostInfoDescriptionOverlayTargetKey = GlobalKey();
final GlobalKey<OverlayTargetState> hostInfoPasswordOverlayTargetKey = GlobalKey();
final GlobalKey<OverlayTargetState> hostShareOverlayTargetKey = GlobalKey();
final GlobalKey<SettingTileState> hostInfoTileKey = GlobalKey();
class HostPage extends RebootPage {
const HostPage({Key? key}) : super(key: key);
@@ -47,7 +52,6 @@ class HostPage extends RebootPage {
class _HostingPageState extends RebootPageState<HostPage> {
final GameController _gameController = Get.find<GameController>();
final HostingController _hostingController = Get.find<HostingController>();
final UpdateController _updateController = Get.find<UpdateController>();
final SettingsController _settingsController = Get.find<SettingsController>();
late final RxBool _showPasswordTrailing = RxBool(_hostingController.password.text.isNotEmpty);
@@ -67,39 +71,32 @@ class _HostingPageState extends RebootPageState<HostPage> {
@override
Widget get button => LaunchButton(
host: true,
startLabel: translations.startHosting,
stopLabel: translations.stopHosting
host: true,
startLabel: translations.startHosting,
stopLabel: translations.stopHosting
);
@override
List<Widget> get settings => [
_information,
versionSelectSettingTile,
buildVersionSelector(
key: hostVersionOverlayTargetKey
),
_options,
_internalFiles,
_share,
_resetDefaults
];
SettingTile get _resetDefaults => SettingTile(
icon: Icon(
FluentIcons.arrow_reset_24_regular
),
title: Text(translations.hostResetName),
subtitle: Text(translations.hostResetDescription),
content: Button(
onPressed: () => showResetDialog(_hostingController.reset),
child: Text(translations.hostResetContent),
)
);
SettingTile get _information => SettingTile(
key: hostInfoTileKey,
icon: Icon(
FluentIcons.info_24_regular
),
title: Text(translations.hostGameServerName),
subtitle: Text(translations.hostGameServerDescription),
overlayKey: hostInfoOverlayTargetKey,
children: [
SettingTile(
icon: Icon(
@@ -107,10 +104,14 @@ class _HostingPageState extends RebootPageState<HostPage> {
),
title: Text(translations.hostGameServerNameName),
subtitle: Text(translations.hostGameServerNameDescription),
content: TextFormBox(
placeholder: translations.hostGameServerNameName,
controller: _hostingController.name,
onChanged: (_) => _updateServer()
content: OverlayTarget(
key: hostInfoNameOverlayTargetKey,
child: TextFormBox(
placeholder: translations.hostGameServerNameName,
controller: _hostingController.name,
focusNode: _hostingController.nameFocusNode,
onChanged: (_) => _updateServer()
),
)
),
SettingTile(
@@ -119,10 +120,14 @@ class _HostingPageState extends RebootPageState<HostPage> {
),
title: Text(translations.hostGameServerDescriptionName),
subtitle: Text(translations.hostGameServerDescriptionDescription),
content: TextFormBox(
placeholder: translations.hostGameServerDescriptionName,
controller: _hostingController.description,
onChanged: (_) => _updateServer()
content: OverlayTarget(
key: hostInfoDescriptionOverlayTargetKey,
child: TextFormBox(
placeholder: translations.hostGameServerDescriptionName,
controller: _hostingController.description,
focusNode: _hostingController.descriptionFocusNode,
onChanged: (_) => _updateServer()
),
)
),
SettingTile(
@@ -131,28 +136,32 @@ class _HostingPageState extends RebootPageState<HostPage> {
),
title: Text(translations.hostGameServerPasswordName),
subtitle: Text(translations.hostGameServerDescriptionDescription),
content: Obx(() => TextFormBox(
placeholder: translations.hostGameServerPasswordName,
controller: _hostingController.password,
autovalidateMode: AutovalidateMode.always,
obscureText: !_hostingController.showPassword.value,
enableSuggestions: false,
autocorrect: false,
onChanged: (text) {
_showPasswordTrailing.value = text.isNotEmpty;
_updateServer();
},
suffix: Button(
onPressed: () => _hostingController.showPassword.value = !_hostingController.showPassword.value,
style: ButtonStyle(
shape: ButtonState.all(const CircleBorder()),
backgroundColor: ButtonState.all(Colors.transparent)
),
child: Icon(
_hostingController.showPassword.value ? FluentIcons.eye_off_24_filled : FluentIcons.eye_24_filled,
color: _showPasswordTrailing.value ? null : Colors.transparent
),
)
content: Obx(() => OverlayTarget(
key: hostInfoPasswordOverlayTargetKey,
child: TextFormBox(
placeholder: translations.hostGameServerPasswordName,
controller: _hostingController.password,
focusNode: _hostingController.passwordFocusNode,
autovalidateMode: AutovalidateMode.always,
obscureText: !_hostingController.showPassword.value,
enableSuggestions: false,
autocorrect: false,
onChanged: (text) {
_showPasswordTrailing.value = text.isNotEmpty;
_updateServer();
},
suffix: Button(
onPressed: () => _hostingController.showPassword.value = !_hostingController.showPassword.value,
style: ButtonStyle(
shape: ButtonState.all(const CircleBorder()),
backgroundColor: ButtonState.all(Colors.transparent)
),
child: Icon(
_hostingController.showPassword.value ? FluentIcons.eye_off_24_filled : FluentIcons.eye_24_filled,
color: _showPasswordTrailing.value ? null : Colors.transparent
),
)
),
))
),
SettingTile(
@@ -183,105 +192,6 @@ class _HostingPageState extends RebootPageState<HostPage> {
]
);
SettingTile get _internalFiles => SettingTile(
icon: Icon(
FluentIcons.archive_settings_24_regular
),
title: Text(translations.settingsServerName),
subtitle: Text(translations.settingsServerSubtitle),
children: [
SettingTile(
icon: Icon(
FluentIcons.timer_24_regular
),
title: Text(translations.settingsServerTypeName),
subtitle: Text(translations.settingsServerTypeDescription),
content: Obx(() => DropDownButton(
onOpen: () => inDialog = true,
onClose: () => inDialog = false,
leading: Text(_updateController.customGameServer.value ? translations.settingsServerTypeCustomName : translations.settingsServerTypeEmbeddedName),
items: {
false: translations.settingsServerTypeEmbeddedName,
true: translations.settingsServerTypeCustomName
}.entries.map((entry) => MenuFlyoutItem(
text: Text(entry.value),
onPressed: () {
final oldValue = _updateController.customGameServer.value;
if(oldValue == entry.key) {
return;
}
_updateController.customGameServer.value = entry.key;
_updateController.infoBarEntry?.close();
if(!entry.key) {
_updateController.updateReboot(
force: true
);
}
}
)).toList()
))
),
Obx(() {
if(!_updateController.customGameServer.value) {
return const SizedBox.shrink();
}
return createFileSetting(
title: translations.settingsServerFileName,
description: translations.settingsServerFileDescription,
controller: _settingsController.gameServerDll
);
}),
Obx(() {
if(_updateController.customGameServer.value) {
return const SizedBox.shrink();
}
return SettingTile(
icon: Icon(
FluentIcons.globe_24_regular
),
title: Text(translations.settingsServerMirrorName),
subtitle: Text(translations.settingsServerMirrorDescription),
content: TextFormBox(
placeholder: translations.settingsServerMirrorPlaceholder,
controller: _updateController.url,
validator: checkUpdateUrl
)
);
}),
Obx(() {
if(_updateController.customGameServer.value) {
return const SizedBox.shrink();
}
return SettingTile(
icon: Icon(
FluentIcons.timer_24_regular
),
title: Text(translations.settingsServerTimerName),
subtitle: Text(translations.settingsServerTimerSubtitle),
content: Obx(() => DropDownButton(
onOpen: () => inDialog = true,
onClose: () => inDialog = false,
leading: Text(_updateController.timer.value.text),
items: UpdateTimer.values.map((entry) => MenuFlyoutItem(
text: Text(entry.text),
onPressed: () {
_updateController.timer.value = entry;
_updateController.infoBarEntry?.close();
_updateController.updateReboot(
force: true
);
}
)).toList()
))
);
}),
],
);
SettingTile get _options => SettingTile(
icon: Icon(
FluentIcons.options_24_regular
@@ -347,12 +257,121 @@ class _HostingPageState extends RebootPageState<HostPage> {
],
);
SettingTile get _internalFiles => SettingTile(
icon: Icon(
FluentIcons.archive_settings_24_regular
),
title: Text(translations.settingsServerName),
subtitle: Text(translations.settingsServerSubtitle),
children: [
SettingTile(
icon: Icon(
FluentIcons.timer_24_regular
),
title: Text(translations.settingsServerTypeName),
subtitle: Text(translations.settingsServerTypeDescription),
content: Obx(() => DropDownButton(
onOpen: () => inDialog = true,
onClose: () => inDialog = false,
leading: Text(_settingsController.customGameServer.value ? translations.settingsServerTypeCustomName : translations.settingsServerTypeEmbeddedName),
items: {
false: translations.settingsServerTypeEmbeddedName,
true: translations.settingsServerTypeCustomName
}.entries.map((entry) => MenuFlyoutItem(
text: Text(entry.value),
onPressed: () {
final oldValue = _settingsController.customGameServer.value;
if(oldValue == entry.key) {
return;
}
_settingsController.customGameServer.value = entry.key;
_settingsController.infoBarEntry?.close();
if(!entry.key) {
_settingsController.updateReboot(
force: true
);
}
}
)).toList()
))
),
Obx(() {
if(!_settingsController.customGameServer.value) {
return const SizedBox.shrink();
}
return createFileSetting(
title: translations.settingsServerFileName,
description: translations.settingsServerFileDescription,
controller: _settingsController.gameServerDll
);
}),
Obx(() {
if(_settingsController.customGameServer.value) {
return const SizedBox.shrink();
}
return SettingTile(
icon: Icon(
FluentIcons.globe_24_regular
),
title: Text(translations.settingsServerMirrorName),
subtitle: Text(translations.settingsServerMirrorDescription),
content: TextFormBox(
placeholder: translations.settingsServerMirrorPlaceholder,
controller: _settingsController.url,
validator: _checkUpdateUrl
)
);
}),
Obx(() {
if(_settingsController.customGameServer.value) {
return const SizedBox.shrink();
}
return SettingTile(
icon: Icon(
FluentIcons.timer_24_regular
),
title: Text(translations.settingsServerTimerName),
subtitle: Text(translations.settingsServerTimerSubtitle),
content: Obx(() => DropDownButton(
onOpen: () => inDialog = true,
onClose: () => inDialog = false,
leading: Text(_settingsController.timer.value.text),
items: UpdateTimer.values.map((entry) => MenuFlyoutItem(
text: Text(entry.text),
onPressed: () {
_settingsController.timer.value = entry;
_settingsController.infoBarEntry?.close();
_settingsController.updateReboot(
force: true
);
}
)).toList()
))
);
}),
],
);
String? _checkUpdateUrl(String? text) {
if (text == null || text.isEmpty) {
return translations.emptyURL;
}
return null;
}
SettingTile get _share => SettingTile(
icon: Icon(
FluentIcons.link_24_regular
),
title: Text(translations.hostShareName),
subtitle: Text(translations.hostShareDescription),
overlayKey: hostShareOverlayTargetKey,
children: [
SettingTile(
icon: Icon(
@@ -382,7 +401,7 @@ class _HostingPageState extends RebootPageState<HostPage> {
final ip = await Ipify.ipv4();
entry.close();
FlutterClipboard.controlC(ip);
_showCopiedIp();
_showCopiedIp();
}catch(error) {
entry?.close();
_showCannotCopyIp(error);
@@ -394,6 +413,18 @@ class _HostingPageState extends RebootPageState<HostPage> {
],
);
SettingTile get _resetDefaults => SettingTile(
icon: Icon(
FluentIcons.arrow_reset_24_regular
),
title: Text(translations.hostResetName),
subtitle: Text(translations.hostResetDescription),
content: Button(
onPressed: () => showResetDialog(_hostingController.reset),
child: Text(translations.hostResetContent),
)
);
Future<void> _updateServer() async {
if(!_hostingController.published()) {
return;
@@ -409,29 +440,29 @@ class _HostingPageState extends RebootPageState<HostPage> {
}
}
void _showCopiedLink() => showInfoBar(
void _showCopiedLink() => showRebootInfoBar(
translations.hostShareLinkMessageSuccess,
severity: InfoBarSeverity.success
);
InfoBarEntry _showCopyingIp() => showInfoBar(
InfoBarEntry _showCopyingIp() => showRebootInfoBar(
translations.hostShareIpMessageLoading,
loading: true,
duration: null
);
void _showCopiedIp() => showInfoBar(
void _showCopiedIp() => showRebootInfoBar(
translations.hostShareIpMessageSuccess,
severity: InfoBarSeverity.success
);
void _showCannotCopyIp(Object error) => showInfoBar(
void _showCannotCopyIp(Object error) => showRebootInfoBar(
translations.hostShareIpMessageError(error.toString()),
severity: InfoBarSeverity.error,
duration: infoBarLongDuration
);
void _showCannotUpdateGameServer(Object error) => showInfoBar(
void _showCannotUpdateGameServer(Object error) => showRebootInfoBar(
translations.cannotUpdateGameServer(error.toString()),
severity: InfoBarSeverity.success,
duration: infoBarLongDuration

View File

@@ -1,80 +1,14 @@
import 'dart:collection';
import 'dart:io';
import 'package:fluent_ui/fluent_ui.dart' hide FluentIcons;
import 'package:get/get.dart';
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/src/controller/settings_controller.dart';
import 'package:reboot_launcher/src/dialog/abstract/info_bar.dart';
import 'package:fluentui_system_icons/fluentui_system_icons.dart';
import 'package:flutter/material.dart';
import 'package:reboot_launcher/src/messenger/implementation/onboard.dart';
import 'package:reboot_launcher/src/page/abstract/page.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/translations.dart';
import 'package:path/path.dart' as path;
import 'package:reboot_launcher/src/widget/info_tile.dart';
import 'package:reboot_launcher/src/widget/setting_tile.dart';
import 'package:url_launcher/url_launcher_string.dart';
class InfoPage extends RebootPage {
static late List<InfoTile> _infoTiles;
static late List<_QuizEntry> _quizEntries;
static Object? initInfoTiles() {
try {
final faqDirectory = Directory("${assetsDirectory.path}\\info\\$currentLocale\\faq");
final infoTiles = SplayTreeMap<int, InfoTile>();
for(final entry in faqDirectory.listSync()) {
if(entry is File) {
final name = Uri.decodeQueryComponent(path.basename(entry.path));
final splitter = name.indexOf(".");
if(splitter == -1) {
continue;
}
final index = int.tryParse(name.substring(0, splitter));
if(index == null) {
continue;
}
final questionName = Uri.decodeQueryComponent(name.substring(splitter + 2));
infoTiles[index] = InfoTile(
title: Text(questionName),
content: Text(entry.readAsStringSync())
);
}
}
_infoTiles = infoTiles.values.toList(growable: false);
final questionsDirectory = Directory("${assetsDirectory.path}\\info\\$currentLocale\\questions");
final questions = SplayTreeMap<int, _QuizEntry>();
for(final entry in questionsDirectory.listSync()) {
if(entry is File) {
final name = Uri.decodeQueryComponent(path.basename(entry.path));
final splitter = name.indexOf(".");
if(splitter == -1) {
continue;
}
final index = int.tryParse(name.substring(0, splitter));
if(index == null) {
continue;
}
final questionName = Uri.decodeQueryComponent(name.substring(splitter + 2));
questions[index] = _QuizEntry(
question: questionName,
options: entry.readAsStringSync().split("\n")
);
}
}
_quizEntries = questions.values.toList(growable: false);
return null;
}catch(error) {
_infoTiles = [];
_quizEntries = [];
return error;
}
}
const InfoPage({Key? key}) : super(key: key);
@override
@@ -87,207 +21,59 @@ class InfoPage extends RebootPage {
String get iconAsset => "assets/images/info.png";
@override
bool hasButton(String? pageName) => Get.find<SettingsController>().firstRun.value && pageName != null;
bool hasButton(String? routeName) => false;
@override
RebootPageType get type => RebootPageType.info;
}
class _InfoPageState extends RebootPageState<InfoPage> {
final SettingsController _settingsController = Get.find<SettingsController>();
late final Rxn<Widget> _quizPage;
static const String _kReportBugUrl = "https://github.com/Auties00/reboot_launcher/issues/new";
static const String _kDiscordInviteUrl = "https://discord.gg/reboot";
@override
List<SettingTile> get settings => [
_discord,
_tutorial,
_reportBug
];
SettingTile get _reportBug => SettingTile(
icon: Icon(
FluentIcons.bug_24_regular
),
title: Text(translations.settingsUtilsBugReportName),
subtitle: Text(translations.settingsUtilsBugReportSubtitle),
content: Button(
onPressed: () => launchUrlString(_kReportBugUrl),
child: Text(translations.settingsUtilsBugReportContent),
)
);
SettingTile get _tutorial => SettingTile(
icon: Icon(
FluentIcons.chat_help_24_regular
),
title: Text(translations.infoVideoName),
subtitle: Text(translations.infoVideoDescription),
content: Button(
onPressed: () => startOnboarding(),
child: Text(translations.infoVideoContent)
)
);
SettingTile get _discord => SettingTile(
icon: Icon(
Icons.discord_outlined
),
title: Text(translations.infoDiscordName),
subtitle: Text(translations.infoDiscordDescription),
content: Button(
onPressed: () => launchUrlString(_kDiscordInviteUrl),
child: Text(translations.infoDiscordContent)
)
);
@override
void initState() {
_quizPage = Rxn(_settingsController.firstRun.value ? _QuizRoute(
entries: InfoPage._quizEntries,
onSuccess: () => _quizPage.value = null
) : null);
super.initState();
}
@override
List<Widget> get settings => InfoPage._infoTiles;
@override
Widget? get button {
if(_quizPage.value == null) {
return null;
}
return Obx(() {
final page = _quizPage.value;
if(page == null) {
return const SizedBox.shrink();
}
return SizedBox(
width: double.infinity,
height: 48,
child: Button(
onPressed: () => Navigator.of(context).push(PageRouteBuilder(
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
settings: RouteSettings(
name: translations.quiz
),
pageBuilder: (context, incoming, outgoing) => page
)),
child: Text(
translations.startQuiz
),
)
);
});
}
}
class _QuizRoute extends StatefulWidget {
final List<_QuizEntry> entries;
final void Function() onSuccess;
const _QuizRoute({
required this.entries,
required this.onSuccess
});
@override
State<_QuizRoute> createState() => _QuizRouteState();
}
class _QuizRouteState extends State<_QuizRoute> with AutomaticKeepAliveClientMixin {
final SettingsController _settingsController = Get.find<SettingsController>();
late final List<RxInt> _selectedIndexes = List.generate(widget.entries.length, (_) => RxInt(-1));
int _triesLeft = 3;
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return Column(
children: [
Expanded(
child: ListView(
children: widget.entries.indexed.expand((entry) {
final selectedIndex = _selectedIndexes[entry.$1];
return [
Text(
"${entry.$1 + 1}. ${entry.$2.question}",
style: TextStyle(
fontSize: 20.0,
fontWeight: FontWeight.w600
),
),
const SizedBox(height: 12.0),
...entry.$2.options.indexed.map<Widget>((value) => Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Obx(() => RadioButton(
checked: value.$1 == selectedIndex.value,
content: Text(value.$2, textAlign: TextAlign.center),
onChanged: (_) => selectedIndex.value = value.$1
)),
)),
const SizedBox(height: 12.0)
];
}).toList()
),
),
const SizedBox(
height: 8.0,
),
SizedBox(
width: double.infinity,
height: 48,
child: Obx(() {
var clickable = true;
for(final index in _selectedIndexes) {
if(index.value == -1) {
clickable = false;
break;
}
}
return Button(
onPressed: clickable ? () async {
if(_triesLeft <= 0) {
return;
}
var right = 0;
final total = widget.entries.length;
for(var i = 0; i < total; i++) {
final selectedIndex = _selectedIndexes[i].value;
final correctIndex = widget.entries[i].correctIndex;
if(selectedIndex == correctIndex) {
right++;
}
}
if(right == total) {
widget.onSuccess();
showInfoBar(
translations.quizSuccess,
severity: InfoBarSeverity.success
);
_settingsController.firstRun.value = false;
Navigator.of(context).pop();
pageIndex.value = RebootPageType.play.index;
return;
}
switch(--_triesLeft) {
case 0:
showInfoBar(
translations.quizFailed(
right,
total,
translations.quizZeroTriesLeft
),
severity: InfoBarSeverity.error
);
await Future.delayed(const Duration(seconds: 1));
exit(0);
case 1:
showInfoBar(
translations.quizFailed(
right,
total,
translations.quizOneTryLeft
),
severity: InfoBarSeverity.error
);
break;
case 2:
showInfoBar(
translations.quizFailed(
right,
total,
translations.quizTwoTriesLeft
),
severity: InfoBarSeverity.error
);
break;
}
} : null,
child: Text(translations.checkQuiz),
);
},
),
)
],
);
}
}
class _QuizEntry {
final String question;
final List<String> options;
late final int correctIndex;
_QuizEntry({required this.question, required this.options}) {
final correct = options.first;
options.shuffle();
correctIndex = options.indexOf(correct);
}
Widget? get button => null;
}

View File

@@ -1,18 +1,19 @@
import 'package:fluent_ui/fluent_ui.dart' hide FluentIcons;
import 'package:fluentui_system_icons/fluentui_system_icons.dart';
import 'package:get/get.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/settings_controller.dart';
import 'package:reboot_launcher/src/messenger/abstract/overlay.dart';
import 'package:reboot_launcher/src/messenger/implementation/onboard.dart';
import 'package:reboot_launcher/src/page/abstract/page.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/translations.dart';
import 'package:reboot_launcher/src/widget/file_setting_tile.dart';
import 'package:reboot_launcher/src/widget/game_start_button.dart';
import 'package:reboot_launcher/src/widget/setting_tile.dart';
import 'package:reboot_launcher/src/widget/version_selector_tile.dart';
final GlobalKey<OverlayTargetState> gameVersionOverlayTargetKey = GlobalKey();
class PlayPage extends RebootPage {
const PlayPage({Key? key}) : super(key: key);
@@ -36,8 +37,49 @@ class PlayPage extends RebootPage {
class _PlayPageState extends RebootPageState<PlayPage> {
final SettingsController _settingsController = Get.find<SettingsController>();
final GameController _gameController = Get.find<GameController>();
final BackendController _backendController = Get.find<BackendController>();
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildFirstLaunchInfo(),
Expanded(
child: super.build(context),
)
],
);
}
Widget _buildFirstLaunchInfo() => Obx(() {
if(!_settingsController.firstRun.value) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(
bottom: 8.0
),
child: SizedBox(
width: double.infinity,
child: InfoBar(
title: Text(translations.welcomeTitle),
severity: InfoBarSeverity.warning,
isLong: true,
content: SizedBox(
width: double.infinity,
child: Text(translations.welcomeDescription)
),
action: Button(
child: Text(translations.welcomeAction),
onPressed: () => startOnboarding(),
),
onClose: () => _settingsController.firstRun.value = false
),
),
);
});
@override
Widget? get button => LaunchButton(
startLabel: translations.launchFortnite,
@@ -47,25 +89,13 @@ class _PlayPageState extends RebootPageState<PlayPage> {
@override
List<SettingTile> get settings => [
versionSelectSettingTile,
buildVersionSelector(
key: gameVersionOverlayTargetKey
),
_options,
_internalFiles,
_multiplayer
];
SettingTile get _multiplayer => SettingTile(
icon: Icon(
FluentIcons.people_24_regular
),
title: Text(translations.playGameServerName),
subtitle: Text(translations.playGameServerDescription),
children: [
_hostSettingTile,
_browseServerTile,
_matchmakerTile,
],
);
SettingTile get _internalFiles => SettingTile(
icon: Icon(
FluentIcons.archive_settings_24_regular
@@ -95,8 +125,8 @@ class _PlayPageState extends RebootPageState<PlayPage> {
icon: Icon(
FluentIcons.options_24_regular
),
title: Text(translations.settingsServerOptionsName),
subtitle: Text(translations.settingsServerOptionsSubtitle),
title: Text(translations.settingsClientOptionsName),
subtitle: Text(translations.settingsClientOptionsDescription),
children: [
SettingTile(
icon: Icon(
@@ -111,34 +141,4 @@ class _PlayPageState extends RebootPageState<PlayPage> {
)
]
);
SettingTile get _matchmakerTile => SettingTile(
onPressed: () {
pageIndex.value = RebootPageType.backend.index;
WidgetsBinding.instance.addPostFrameCallback((_) => _backendController.gameServerAddressFocusNode.requestFocus());
},
icon: Icon(
FluentIcons.globe_24_regular
),
title: Text(translations.playGameServerCustomName),
subtitle: Text(translations.playGameServerCustomDescription),
);
SettingTile get _browseServerTile => SettingTile(
onPressed: () => pageIndex.value = RebootPageType.browser.index,
icon: Icon(
FluentIcons.search_24_regular
),
title: Text(translations.playGameServerBrowserName),
subtitle: Text(translations.playGameServerBrowserDescription)
);
SettingTile get _hostSettingTile => SettingTile(
onPressed: () => pageIndex.value = RebootPageType.host.index,
icon: Icon(
FluentIcons.desktop_24_regular
),
title: Text(translations.playGameServerHostName),
subtitle: Text(translations.playGameServerHostDescription),
);
}

View File

@@ -1,237 +0,0 @@
import 'dart:async';
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/backend_controller.dart';
import 'package:reboot_launcher/src/controller/hosting_controller.dart';
import 'package:reboot_launcher/src/dialog/implementation/server.dart';
import 'package:reboot_launcher/src/page/abstract/page.dart';
import 'package:reboot_launcher/src/page/abstract/page_type.dart';
import 'package:reboot_launcher/src/util/translations.dart';
import 'package:reboot_launcher/src/widget/setting_tile.dart';
class BrowsePage extends RebootPage {
const BrowsePage({Key? key}) : super(key: key);
@override
String get name => translations.browserName;
@override
RebootPageType get type => RebootPageType.browser;
@override
String get iconAsset => "assets/images/server_browser.png";
@override
bool hasButton(String? pageName) => false;
@override
RebootPageState<BrowsePage> createState() => _BrowsePageState();
}
class _BrowsePageState extends RebootPageState<BrowsePage> {
final HostingController _hostingController = Get.find<HostingController>();
final BackendController _backendController = Get.find<BackendController>();
final TextEditingController _filterController = TextEditingController();
final StreamController<String> _filterControllerStream = StreamController.broadcast();
@override
Widget build(BuildContext context) {
super.build(context);
return Obx(() {
var data = _hostingController.servers.value
?.where((entry) => (kDebugMode || entry["id"] != _hostingController.uuid) && entry["discoverable"])
.toSet();
if(data == null || data.isEmpty == true) {
return _noServers;
}
return _buildPageBody(data);
});
}
Widget get _noServers => Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
translations.noServersAvailableTitle,
style: FluentTheme.of(context).typography.titleLarge,
),
Text(
translations.noServersAvailableSubtitle,
style: FluentTheme.of(context).typography.body
),
],
);
Widget _buildPageBody(Set<Map<String, dynamic>> data) => StreamBuilder(
stream: _filterControllerStream.stream,
builder: (context, filterSnapshot) {
final items = data.where((entry) => _isValidItem(entry, filterSnapshot.data)).toSet();
return Column(
children: [
_searchBar,
const SizedBox(
height: 16,
),
Expanded(
child: items.isEmpty ? _noServersByQuery : _buildPopulatedListBody(items)
),
],
);
}
);
Widget _buildPopulatedListBody(Set<Map<String, dynamic>> items) => ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
var entry = items.elementAt(index ~/ 2);
var hasPassword = entry["password"] != null;
return SettingTile(
icon: Icon(
hasPassword ? FluentIcons.lock : FluentIcons.globe
),
title: Text("${_formatName(entry)}${entry["author"]}"),
subtitle: Text("${_formatDescription(entry)}${_formatVersion(entry)}"),
content: Button(
onPressed: () => _backendController.joinServer(_hostingController.uuid, entry),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_backendController.type.value == ServerType.embedded ? translations.joinServer : translations.copyIp),
],
),
)
);
}
);
Widget get _noServersByQuery => Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
translations.noServersAvailableByQueryTitle,
style: FluentTheme.of(context).typography.titleLarge,
),
Text(
translations.noServersAvailableByQuerySubtitle,
style: FluentTheme.of(context).typography.body
),
],
);
bool _isValidItem(Map<String, dynamic> entry, String? filter) =>
filter == null || filter.isEmpty || _filterServer(entry, filter);
bool _filterServer(Map<String, dynamic> element, String filter) {
String? id = element["id"];
if(id?.toLowerCase().contains(filter.toLowerCase()) == true) {
return true;
}
var uri = Uri.tryParse(filter);
if(uri != null
&& uri.host.isNotEmpty
&& id?.toLowerCase().contains(uri.host.toLowerCase()) == true) {
return true;
}
String? name = element["name"];
if(name?.toLowerCase().contains(filter) == true) {
return true;
}
String? author = element["author"];
if(author?.toLowerCase().contains(filter) == true) {
return true;
}
String? description = element["description"];
if(description?.toLowerCase().contains(filter) == true) {
return true;
}
return false;
}
Widget get _searchBar => Align(
alignment: Alignment.centerLeft,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 350
),
child: TextBox(
placeholder: translations.findServer,
controller: _filterController,
autofocus: true,
onChanged: (value) => _filterControllerStream.add(value),
suffix: _searchBarIcon,
),
),
);
Widget get _searchBarIcon => Button(
onPressed: _filterController.text.isEmpty ? null : () {
_filterController.clear();
_filterControllerStream.add("");
},
style: ButtonStyle(
backgroundColor: ButtonState.all(Colors.transparent),
shape: ButtonState.all(Border())
),
child: _searchBarIconData
);
Widget get _searchBarIconData {
var color = FluentTheme.of(context).resources.textFillColorPrimary;
if (_filterController.text.isNotEmpty) {
return Icon(
FluentIcons.clear,
size: 8.0,
color: color
);
}
return Transform.flip(
flipX: true,
child: Icon(
FluentIcons.search,
size: 12.0,
color: color
),
);
}
String _formatName(Map<String, dynamic> entry) {
String result = entry['name'];
return result.isEmpty ? translations.defaultServerName : result;
}
String _formatDescription(Map<String, dynamic> entry) {
String result = entry['description'];
return result.isEmpty ? translations.defaultServerDescription : result;
}
String _formatVersion(Map<String, dynamic> entry) {
var version = entry['version'];
var versionSplit = version.indexOf("-");
var minimalVersion = version = versionSplit != -1 ? version.substring(0, versionSplit) : version;
String result = minimalVersion.endsWith(".0") ? minimalVersion.substring(0, minimalVersion.length - 2) : minimalVersion;
if(result.toLowerCase().startsWith("fortnite ")) {
result = result.substring(0, 10);
}
return "Fortnite $result";
}
@override
Widget? get button => null;
@override
List<Widget> get settings => [];
}

View File

@@ -5,8 +5,8 @@ import 'package:flutter_localized_locales/flutter_localized_locales.dart';
import 'package:get/get.dart';
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/src/controller/settings_controller.dart';
import 'package:reboot_launcher/src/dialog/abstract/dialog.dart';
import 'package:reboot_launcher/src/dialog/implementation/data.dart';
import 'package:reboot_launcher/src/messenger/abstract/dialog.dart';
import 'package:reboot_launcher/src/messenger/implementation/data.dart';
import 'package:reboot_launcher/src/page/abstract/page.dart';
import 'package:reboot_launcher/src/page/abstract/page_type.dart';
import 'package:reboot_launcher/src/util/translations.dart';
@@ -40,52 +40,67 @@ class _SettingsPageState extends RebootPageState<SettingsPage> {
@override
List<Widget> get settings => [
SettingTile(
icon: Icon(
FluentIcons.local_language_24_regular
),
title: Text(translations.settingsUtilsLanguageName),
subtitle: Text(translations.settingsUtilsLanguageDescription),
content: Obx(() => DropDownButton(
onOpen: () => inDialog = true,
onClose: () => inDialog = false,
leading: Text(_getLocaleName(_settingsController.language.value)),
items: AppLocalizations.supportedLocales.map((locale) => MenuFlyoutItem(
text: Text(_getLocaleName(locale.languageCode)),
onPressed: () => _settingsController.language.value = locale.languageCode
)).toList()
))
),
SettingTile(
icon: Icon(
FluentIcons.dark_theme_24_regular
),
title: Text(translations.settingsUtilsThemeName),
subtitle: Text(translations.settingsUtilsThemeDescription),
content: Obx(() => DropDownButton(
onOpen: () => inDialog = true,
onClose: () => inDialog = false,
leading: Text(_settingsController.themeMode.value.title),
items: ThemeMode.values.map((themeMode) => MenuFlyoutItem(
text: Text(themeMode.title),
onPressed: () => _settingsController.themeMode.value = themeMode
)).toList()
))
),
SettingTile(
icon: Icon(
FluentIcons.arrow_reset_24_regular
),
title: Text(translations.settingsUtilsResetDefaultsName),
subtitle: Text(translations.settingsUtilsResetDefaultsSubtitle),
content: Button(
onPressed: () => showResetDialog(_settingsController.reset),
child: Text(translations.settingsUtilsResetDefaultsContent),
)
),
_language,
_theme,
_resetDefaults,
_installationDirectory
];
SettingTile get _language => SettingTile(
icon: Icon(
FluentIcons.local_language_24_regular
),
title: Text(translations.settingsUtilsLanguageName),
subtitle: Text(translations.settingsUtilsLanguageDescription),
content: Obx(() => DropDownButton(
onOpen: () => inDialog = true,
onClose: () => inDialog = false,
leading: Text(_getLocaleName(_settingsController.language.value)),
items: AppLocalizations.supportedLocales.map((locale) => MenuFlyoutItem(
text: Text(_getLocaleName(locale.languageCode)),
onPressed: () => _settingsController.language.value = locale.languageCode
)).toList()
))
);
String _getLocaleName(String locale) {
var result = LocaleNames.of(context)!.nameOf(locale);
if(result != null) {
return "${result.substring(0, 1).toUpperCase()}${result.substring(1).toLowerCase()}";
}
return locale;
}
SettingTile get _theme => SettingTile(
icon: Icon(
FluentIcons.dark_theme_24_regular
),
title: Text(translations.settingsUtilsThemeName),
subtitle: Text(translations.settingsUtilsThemeDescription),
content: Obx(() => DropDownButton(
onOpen: () => inDialog = true,
onClose: () => inDialog = false,
leading: Text(_settingsController.themeMode.value.title),
items: ThemeMode.values.map((themeMode) => MenuFlyoutItem(
text: Text(themeMode.title),
onPressed: () => _settingsController.themeMode.value = themeMode
)).toList()
))
);
SettingTile get _resetDefaults => SettingTile(
icon: Icon(
FluentIcons.arrow_reset_24_regular
),
title: Text(translations.settingsUtilsResetDefaultsName),
subtitle: Text(translations.settingsUtilsResetDefaultsSubtitle),
content: Button(
onPressed: () => showResetDialog(_settingsController.reset),
child: Text(translations.settingsUtilsResetDefaultsContent),
)
);
SettingTile get _installationDirectory => SettingTile(
icon: Icon(
FluentIcons.folder_24_regular
@@ -97,15 +112,6 @@ class _SettingsPageState extends RebootPageState<SettingsPage> {
child: Text(translations.settingsUtilsInstallationDirectoryContent),
)
);
String _getLocaleName(String locale) {
var result = LocaleNames.of(context)!.nameOf(locale);
if(result != null) {
return "${result.substring(0, 1).toUpperCase()}${result.substring(1).toLowerCase()}";
}
return locale;
}
}
extension _ThemeModeExtension on ThemeMode {

View File

@@ -3,14 +3,14 @@ import 'dart:collection';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:get/get.dart';
import 'package:reboot_launcher/src/controller/settings_controller.dart';
import 'package:reboot_launcher/src/messenger/abstract/overlay.dart';
import 'package:reboot_launcher/src/page/abstract/page.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/browser_page.dart';
import 'package:reboot_launcher/src/page/implementation/host_page.dart';
import 'package:reboot_launcher/src/page/implementation/info_page.dart';
import 'package:reboot_launcher/src/page/implementation/play_page.dart';
import 'package:reboot_launcher/src/page/implementation/server_browser_page.dart';
import 'package:reboot_launcher/src/page/implementation/server_host_page.dart';
import 'package:reboot_launcher/src/page/implementation/settings_page.dart';
import 'package:reboot_launcher/src/widget/info_bar_area.dart';
@@ -26,15 +26,15 @@ final List<RebootPage> pages = [
const SettingsPage()
];
final RxInt pageIndex = _initialPageIndex;
RxInt get _initialPageIndex {
final settingsController = Get.find<SettingsController>();
return RxInt(settingsController.firstRun.value ? RebootPageType.info.index : RebootPageType.play.index);
}
final List<GlobalKey<OverlayTargetState>> _flyoutPageControllers = List.generate(pages.length, (_) => GlobalKey());
final RxInt pageIndex = RxInt(RebootPageType.play.index);
final HashMap<int, GlobalKey> _pageKeys = HashMap();
final GlobalKey appKey = GlobalKey();
final GlobalKey<NavigatorState> appNavigatorKey = GlobalKey();
final GlobalKey<OverlayState> appOverlayKey = GlobalKey();
final GlobalKey<InfoBarAreaState> infoBarAreaKey = GlobalKey();
@@ -79,4 +79,8 @@ void addSubPageToStack(String pageName) {
appStack.add(identifier);
_pagesStack[index]!.add(identifier);
pagesController.add(null);
}
}
GlobalKey<OverlayTargetState> getOverlayTargetKeyByPage(int pageIndex) => _flyoutPageControllers[pageIndex];
GlobalKey<OverlayTargetState> get pageOverlayTargetKey => _flyoutPageControllers[pageIndex.value];

View File

@@ -1,86 +0,0 @@
import 'dart:io';
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/src/util/translations.dart';
String? checkVersion(String? text, List<FortniteVersion> versions) {
if (text == null || text.isEmpty) {
return translations.emptyVersionName;
}
if (versions.any((element) => element.name == text)) {
return translations.versionAlreadyExists;
}
return null;
}
String? checkChangeVersion(String? text) {
if (text == null || text.isEmpty) {
return translations.emptyVersionName;
}
return null;
}
String? checkGameFolder(text) {
if (text == null || text.isEmpty) {
return translations.emptyGamePath;
}
var directory = Directory(text);
if (!directory.existsSync()) {
return translations.directoryDoesNotExist;
}
if (FortniteVersionExtension.findExecutable(directory, "FortniteClient-Win64-Shipping.exe") == null) {
return translations.missingShippingExe;
}
return null;
}
String? checkDownloadDestination(text) {
if (text == null || text.isEmpty) {
return translations.invalidDownloadPath;
}
return null;
}
String? checkDll(String? text) {
if (text == null || text.isEmpty) {
return translations.invalidDllPath;
}
if (!File(text).existsSync()) {
return translations.dllDoesNotExist;
}
if (!text.endsWith(".dll")) {
return translations.invalidDllExtension;
}
return null;
}
String? checkMatchmaking(String? text) {
if (text == null || text.isEmpty) {
return translations.emptyHostname;
}
var ipParts = text.split(":");
if(ipParts.length > 2){
return translations.hostnameFormat;
}
return null;
}
String? checkUpdateUrl(String? text) {
if (text == null || text.isEmpty) {
return translations.emptyURL;
}
return null;
}

View File

@@ -1,107 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:get/get.dart';
import 'package:path/path.dart' as path;
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/src/controller/settings_controller.dart';
import 'package:reboot_launcher/src/controller/update_controller.dart';
import 'package:reboot_launcher/src/dialog/abstract/info_bar.dart';
import 'package:reboot_launcher/src/util/translations.dart';
final UpdateController _updateController = Get.find<UpdateController>();
final Map<String, Future<void>> _operations = {};
Future<void> downloadCriticalDllInteractive(String filePath, {bool silent = false}) {
final old = _operations[filePath];
if(old != null) {
return old;
}
final newRun = _downloadCriticalDllInteractive(filePath, silent);
_operations[filePath] = newRun;
return newRun;
}
Future<void> _downloadCriticalDllInteractive(String filePath, bool silent) async {
final fileName = path.basename(filePath).toLowerCase();
InfoBarEntry? entry;
try {
if (fileName == "reboot.dll") {
await _updateController.updateReboot(
silent: silent
);
return;
}
if(File(filePath).existsSync()) {
return;
}
final fileNameWithoutExtension = path.basenameWithoutExtension(filePath);
if(!silent) {
entry = showInfoBar(
translations.downloadingDll(fileNameWithoutExtension),
loading: true,
duration: null
);
}
await downloadCriticalDll(fileName, filePath);
entry?.close();
if(!silent) {
entry = await showInfoBar(
translations.downloadDllSuccess(fileNameWithoutExtension),
severity: InfoBarSeverity.success,
duration: infoBarShortDuration
);
}
}catch(message) {
if(!silent) {
entry?.close();
var error = message.toString();
error = error.contains(": ") ? error.substring(error.indexOf(": ") + 2) : error;
error = error.toLowerCase();
final completer = Completer();
await showInfoBar(
translations.downloadDllError(fileName, error.toString()),
duration: infoBarLongDuration,
severity: InfoBarSeverity.error,
onDismissed: () => completer.complete(null),
action: Button(
onPressed: () async {
await downloadCriticalDllInteractive(filePath);
completer.complete(null);
},
child: Text(translations.downloadDllRetry),
)
);
await completer.future;
}
}finally {
_operations.remove(fileName);
}
}
extension InjectableDllExtension on InjectableDll {
String get path {
final SettingsController settingsController = Get.find<SettingsController>();
switch(this){
case InjectableDll.reboot:
if(_updateController.customGameServer.value) {
final file = File(settingsController.gameServerDll.text);
if(file.existsSync()) {
return file.path;
}
}
return rebootDllFile.path;
case InjectableDll.console:
return settingsController.unrealEngineConsoleDll.text;
case InjectableDll.cobalt:
return settingsController.backendDll.text;
case InjectableDll.memory:
return settingsController.memoryLeakDll.text;
}
}
}

View File

@@ -1,29 +0,0 @@
import 'dart:io';
import 'package:reboot_common/common.dart';
import 'package:sync/semaphore.dart';
final File _loggingFile = _createLoggingFile();
final Semaphore _semaphore = Semaphore(1);
File _createLoggingFile() {
final file = File("${logsDirectory.path}\\launcher.log");
file.parent.createSync(recursive: true);
if(file.existsSync()) {
file.deleteSync();
}
file.createSync();
return file;
}
void log(String message) async {
try {
await _semaphore.acquire();
print(message);
await _loggingFile.writeAsString("$message\n", mode: FileMode.append, flush: true);
}catch(error) {
print(error);
}finally {
_semaphore.release();
}
}

View File

@@ -3,45 +3,52 @@ import 'dart:io';
import 'package:reboot_common/common.dart';
const Duration _timeout = Duration(seconds: 2);
Future<bool> _pingGameServer(String hostname, int port) async {
RawDatagramSocket? socket;
try {
socket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0);
final dataToSend = utf8.encode(DateTime.now().toIso8601String());
socket.send(dataToSend, InternetAddress(hostname), port);
await for (var event in socket) {
switch(event) {
case RawSocketEvent.read:
case RawSocketEvent.write:
return true;
case RawSocketEvent.readClosed:
case RawSocketEvent.closed:
return false;
}
}
return false;
}finally {
socket?.close();
}
}
Future<bool> get _timeoutFuture => Future.delayed(_timeout).then((value) => false);
const Duration _timeout = Duration(seconds: 5);
Future<bool> pingGameServer(String address, {Duration? timeout}) async {
var start = DateTime.now();
var firstTime = true;
while (firstTime || (timeout != null && DateTime.now().millisecondsSinceEpoch - start.millisecondsSinceEpoch < timeout.inMilliseconds)) {
var split = address.split(":");
var hostname = split[0];
if(isLocalHost(hostname)) {
hostname = "127.0.0.1";
}
Future<bool> ping(String hostname, int port) async {
log("[MATCHMAKER] Pinging $hostname:$port");
RawDatagramSocket? socket;
try {
socket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0);
await for (final event in socket) {
log("[MATCHMAKER] Event: $event");
switch(event) {
case RawSocketEvent.read:
log("[MATCHMAKER] Success");
return true;
case RawSocketEvent.write:
log("[MATCHMAKER] Sending data");
final dataToSend = base64Decode("AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABA==");
socket.send(dataToSend, InternetAddress(hostname), port);
case RawSocketEvent.readClosed:
case RawSocketEvent.closed:
return false;
}
}
var port = int.parse(split.length > 1 ? split[1] : kDefaultGameServerPort);
var result = await Future.any([_timeoutFuture, _pingGameServer(hostname, port)]);
return false;
}catch(error) {
log("[MATCHMAKER] Error: $error");
return false;
}finally {
socket?.close();
}
}
final start = DateTime.now();
var firstTime = true;
final split = address.split(":");
var hostname = split[0];
if(isLocalHost(hostname)) {
hostname = "127.0.0.1";
}
final port = int.parse(split.length > 1 ? split[1] : kDefaultGameServerPort);
while (firstTime || (timeout != null && DateTime.now().millisecondsSinceEpoch - start.millisecondsSinceEpoch < timeout.inMilliseconds)) {
final result = await ping(hostname, port)
.timeout(_timeout, onTimeout: () => false);
if(result) {
return true;
}

View File

@@ -3,10 +3,10 @@ import 'dart:ffi';
import 'dart:io';
import 'package:ffi/ffi.dart';
import 'package:file_picker/file_picker.dart';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:flutter/scheduler.dart';
import 'package:win32/win32.dart';
import 'package:file_picker/file_picker.dart';
final RegExp _winBuildRegex = RegExp(r'(?<=\(Build )(.*)(?=\))');

View File

@@ -1,103 +0,0 @@
import 'dart:io';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:get/get.dart';
import 'package:path/path.dart' as path;
import 'package:reboot_common/common.dart';
import 'package:reboot_launcher/src/controller/game_controller.dart';
import 'package:reboot_launcher/src/dialog/abstract/dialog.dart';
import 'package:reboot_launcher/src/dialog/abstract/dialog_button.dart';
import 'package:reboot_launcher/src/util/checks.dart';
import 'package:reboot_launcher/src/util/translations.dart';
import 'package:reboot_launcher/src/widget/file_selector.dart';
import 'package:reboot_launcher/src/widget/version_name_input.dart';
class AddLocalVersion extends StatefulWidget {
const AddLocalVersion({Key? key})
: super(key: key);
@override
State<AddLocalVersion> createState() => _AddLocalVersionState();
}
class _AddLocalVersionState extends State<AddLocalVersion> {
final GameController _gameController = Get.find<GameController>();
final TextEditingController _nameController = TextEditingController();
final TextEditingController _gamePathController = TextEditingController();
@override
void initState() {
_gamePathController.addListener(() async {
var file = Directory(_gamePathController.text);
if(await file.exists()) {
if(_nameController.text.isEmpty) {
var text = path.basename(_gamePathController.text);
_nameController.text = text;
_nameController.selection = TextSelection.collapsed(offset: text.length);
}
}
});
super.initState();
}
@override
Widget build(BuildContext context) {
return FormDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: double.infinity,
child: InfoBar(
title: Text(translations.localBuildsWarning),
severity: InfoBarSeverity.info
),
),
const SizedBox(
height: 16.0
),
VersionNameInput(
controller: _nameController
),
const SizedBox(
height: 16.0
),
FileSelector(
label: translations.gameFolderTitle,
placeholder: translations.gameFolderPlaceholder,
windowTitle: translations.gameFolderPlaceWindowTitle,
controller: _gamePathController,
validator: checkGameFolder,
folder: true
),
const SizedBox(
height: 16.0
)
],
),
buttons: [
DialogButton(
type: ButtonType.secondary
),
DialogButton(
text: translations.saveLocalVersion,
type: ButtonType.primary,
onTap: () {
Navigator.of(context).pop();
WidgetsBinding.instance.addPostFrameCallback((_) => _gameController.addVersion(FortniteVersion(
name: _nameController.text,
location: Directory(_gamePathController.text)
)));
},
)
]
);
}
}

View File

@@ -1,347 +0,0 @@
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/build_controller.dart';
import 'package:reboot_launcher/src/controller/game_controller.dart';
import 'package:reboot_launcher/src/dialog/abstract/dialog.dart';
import 'package:reboot_launcher/src/dialog/abstract/dialog_button.dart';
import 'package:reboot_launcher/src/util/checks.dart';
import 'package:reboot_launcher/src/util/translations.dart';
import 'package:reboot_launcher/src/widget/file_selector.dart';
import 'package:reboot_launcher/src/widget/version_name_input.dart';
import 'package:universal_disk_space/universal_disk_space.dart';
import 'package:windows_taskbar/windows_taskbar.dart';
class AddServerVersion extends StatefulWidget {
const AddServerVersion({Key? key}) : super(key: key);
@override
State<AddServerVersion> createState() => _AddServerVersionState();
}
class _AddServerVersionState extends State<AddServerVersion> {
final GameController _gameController = Get.find<GameController>();
final BuildController _buildController = Get.find<BuildController>();
final TextEditingController _nameController = TextEditingController();
final TextEditingController _pathController = TextEditingController();
final Rx<DownloadStatus> _status = Rx(DownloadStatus.form);
final GlobalKey<FormState> _formKey = GlobalKey();
final RxnInt _timeLeft = RxnInt();
final Rxn<double> _progress = Rxn();
late DiskSpace _diskSpace;
late Future _fetchFuture;
late Future _diskFuture;
Isolate? _isolate;
SendPort? _downloadPort;
Object? _error;
StackTrace? _stackTrace;
@override
void initState() {
_fetchFuture = _buildController.builds != null
? Future.value(true)
: compute(fetchBuilds, null)
.then((value) => _buildController.builds = value);
_diskSpace = DiskSpace();
_diskFuture = _diskSpace.scan()
.then((_) => _updateFormDefaults());
super.initState();
}
@override
void dispose() {
_pathController.dispose();
_nameController.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]),
builder: (context, snapshot) {
if (snapshot.hasError) {
WidgetsBinding.instance.addPostFrameCallback((_) => _onDownloadError(snapshot.error, snapshot.stackTrace));
}
if (!snapshot.hasData) {
return ProgressDialog(
text: translations.fetchingBuilds,
onStop: () => Navigator.of(context).pop()
);
}
return FormDialog(
content: _formBody,
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 => [
DialogButton(type: ButtonType.secondary),
DialogButton(
text: translations.download,
type: ButtonType.primary,
onTap: () => _startDownload(context),
)
];
void _startDownload(BuildContext context) async {
try {
final build = _buildController.selectedBuild;
if(build == null){
return;
}
_status.value = DownloadStatus.downloading;
final communicationPort = ReceivePort();
communicationPort.listen((message) {
if(message is FortniteBuildDownloadProgress) {
_onProgress(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() async {
if (!mounted) {
return;
}
_status.value = DownloadStatus.done;
WindowsTaskbar.setProgressMode(TaskbarProgressMode.noProgress);
WidgetsBinding.instance.addPostFrameCallback((_) => _gameController.addVersion(FortniteVersion(
name: _nameController.text,
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(double progress, int? timeLeft, bool extracting) {
if (!mounted) {
return;
}
if(progress >= 100 && extracting) {
_onDownloadComplete();
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 get _formBody => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSelector(),
const SizedBox(
height: 16.0
),
VersionNameInput(
controller: _nameController
),
const SizedBox(
height: 16.0
),
FileSelector(
label: translations.buildInstallationDirectory,
placeholder: translations.buildInstallationDirectoryPlaceholder,
windowTitle: translations.buildInstallationDirectoryWindowTitle,
controller: _pathController,
validator: checkDownloadDestination,
folder: true
),
const SizedBox(
height: 16.0
)
],
);
Widget _buildSelector() => InfoLabel(
label: translations.build,
child: Obx(() => ComboBox<FortniteBuild>(
placeholder: Text(translations.selectBuild),
isExpanded: true,
items: _builds,
value: _buildController.selectedBuild,
onChanged: (value) {
if(value == null){
return;
}
_buildController.selectedBuild = value;
_updateFormDefaults();
}
))
);
List<ComboBoxItem<FortniteBuild>> get _builds => _buildController.builds!
.map((element) => _buildItem(element))
.toList();
ComboBoxItem<FortniteBuild> _buildItem(FortniteBuild element) => ComboBoxItem<FortniteBuild>(
value: element,
child: Text(element.version.toString())
);
List<DialogButton> get _stopButton => [
DialogButton(
text: "Stop",
type: ButtonType.only
)
];
Future<void> _updateFormDefaults() async {
if(_diskSpace.disks.isEmpty){
return;
}
await _fetchFuture;
final bestDisk = _diskSpace.disks
.reduce((first, second) => first.availableSpace > second.availableSpace ? first : second);
final build = _buildController.selectedBuild;
if(build == null){
return;
}
final pathText = "${bestDisk.devicePath}\\FortniteBuilds\\${build.version}";
_pathController.text = pathText;
_pathController.selection = TextSelection.collapsed(offset: pathText.length);
final buildName = build.version.toString();
_nameController.text = buildName;
_nameController.selection = TextSelection.collapsed(offset: buildName.length);
_formKey.currentState?.validate();
}
}
enum DownloadStatus { form, downloading, extracting, error, done }

View File

@@ -1,6 +1,7 @@
import 'dart:io';
import 'package:fluent_ui/fluent_ui.dart' hide FluentIcons;
import 'package:fluentui_system_icons/fluentui_system_icons.dart';
import 'package:reboot_launcher/src/util/checks.dart';
import 'package:reboot_launcher/src/util/translations.dart';
import 'package:reboot_launcher/src/widget/file_selector.dart';
import 'package:reboot_launcher/src/widget/setting_tile.dart';
@@ -15,8 +16,26 @@ SettingTile createFileSetting({required String title, required String descriptio
placeholder: translations.selectPathPlaceholder,
windowTitle: translations.selectPathWindowTitle,
controller: controller,
validator: checkDll,
validator: _checkDll,
extension: "dll",
folder: false
folder: false,
validatorMode: AutovalidateMode.always
)
);
);
String? _checkDll(String? text) {
if (text == null || text.isEmpty) {
return translations.invalidDllPath;
}
final file = File(text);
if (!file.existsSync()) {
return translations.dllDoesNotExist;
}
if (!text.endsWith(".dll")) {
return translations.invalidDllExtension;
}
return null;
}

View File

@@ -4,7 +4,6 @@ import 'dart:io';
import 'package:async/async.dart';
import 'package:dart_ipify/dart_ipify.dart';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import 'package:local_notifier/local_notifier.dart';
import 'package:path/path.dart';
@@ -13,17 +12,16 @@ 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/dialog/abstract/dialog.dart';
import 'package:reboot_launcher/src/dialog/abstract/dialog_button.dart';
import 'package:reboot_launcher/src/dialog/abstract/info_bar.dart';
import 'package:reboot_launcher/src/dialog/implementation/server.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/messenger/implementation/server.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/dll.dart';
import 'package:reboot_launcher/src/util/log.dart';
import 'package:reboot_launcher/src/util/matchmaker.dart';
import 'package:reboot_launcher/src/util/os.dart';
import 'package:reboot_launcher/src/util/translations.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:url_launcher/url_launcher_string.dart';
class LaunchButton extends StatefulWidget {
final bool host;
@@ -43,6 +41,7 @@ class _LaunchButtonState extends State<LaunchButton> {
final HostingController _hostingController = Get.find<HostingController>();
final BackendController _backendController = Get.find<BackendController>();
final SettingsController _settingsController = Get.find<SettingsController>();
InfoBarEntry? _gameClientInfoBar;
InfoBarEntry? _gameServerInfoBar;
CancelableOperation? _operation;
@@ -95,6 +94,10 @@ class _LaunchButtonState extends State<LaunchButton> {
log("[${host ? 'HOST' : 'GAME'}] Checking dlls: ${InjectableDll.values}");
for (final injectable in InjectableDll.values) {
if(await _getDllFileOrStop(injectable, host) == null) {
_onStop(
reason: _StopReason.missingCustomDllError,
error: injectable.name,
);
return;
}
}
@@ -124,12 +127,16 @@ class _LaunchButtonState extends State<LaunchButton> {
log("[${host ? 'HOST' : 'GAME'}] Implicit game server metadata: headless($serverType)");
final linkedHostingInstance = await _startMatchMakingServer(version, host, serverType, false);
log("[${host ? 'HOST' : 'GAME'}] Implicit game server result: $linkedHostingInstance");
await _startGameProcesses(version, host, serverType, linkedHostingInstance);
if(!host) {
_showLaunchingGameClientWidget();
final result = await _startGameProcesses(version, host, serverType, linkedHostingInstance);
final started = host ? _hostingController.started() : _gameController.started();
if(!started) {
result?.kill();
return;
}
if(linkedHostingInstance != null || host){
if(!host) {
_showLaunchingGameClientWidget(version, serverType, linkedHostingInstance != null);
}else {
_showLaunchingGameServerWidget();
}
} catch (exception, stackTrace) {
@@ -148,7 +155,7 @@ class _LaunchButtonState extends State<LaunchButton> {
return null;
}
if(_backendController.type.value == ServerType.embedded && !isLocalHost(_backendController.gameServerAddress.text)) {
if(!forceLinkedHosting && _backendController.type.value == ServerType.embedded && !isLocalHost(_backendController.gameServerAddress.text)) {
log("[${host ? 'HOST' : 'GAME'}] Backend is not set to embedded and/or not pointing to the local game server");
return null;
}
@@ -158,7 +165,7 @@ class _LaunchButtonState extends State<LaunchButton> {
return null;
}
final response = forceLinkedHosting || await _askForAutomaticGameServer();
final response = forceLinkedHosting || await _askForAutomaticGameServer(host);
if(!response) {
log("[${host ? 'HOST' : 'GAME'}] The user disabled the automatic server");
return null;
@@ -172,19 +179,24 @@ class _LaunchButtonState extends State<LaunchButton> {
return instance;
}
Future<bool> _askForAutomaticGameServer() async {
final result = await showAppDialog<bool>(
Future<bool> _askForAutomaticGameServer(bool host) async {
if (host ? !_hostingController.started() : !_gameController.started()) {
log("[${host ? 'HOST' : 'GAME'}] User asked to close the current instance");
_onStop(reason: _StopReason.normal);
return false;
}
final result = await showRebootDialog<bool>(
builder: (context) => InfoDialog(
text: "The launcher detected that you are not running a game server, but that your matchmaker is set to your local machine. "
"If you don't want to join another player's server, you should start a game server. This is necessary to be able to play: for more information check the Info tab in the launcher.",
text: translations.automaticGameServerDialogContent,
buttons: [
DialogButton(
type: ButtonType.secondary,
text: "Ignore"
text: translations.automaticGameServerDialogIgnore
),
DialogButton(
type: ButtonType.primary,
text: "Start server",
text: translations.automaticGameServerDialogStart,
onTap: () => Navigator.of(context).pop(true),
),
],
@@ -214,7 +226,7 @@ class _LaunchButtonState extends State<LaunchButton> {
log("[${host ? 'HOST' : 'GAME'}] Created game process: ${gameProcess}");
final instance = GameInstance(
versionName: version.name,
versionName: version.content.toString(),
gamePid: gameProcess,
launcherPid: launcherProcess,
eacPid: eacProcess,
@@ -247,7 +259,10 @@ class _LaunchButtonState extends State<LaunchButton> {
executable: executable,
args: gameArgs,
useTempBatch: false,
name: "${version.name}-${host ? 'HOST' : 'GAME'}"
name: "${version.content}-${host ? 'HOST' : 'GAME'}",
environment: {
"OPENSSL_ia32cap": "~0x20000000"
}
);
void onGameOutput(String line, bool error) {
log("[${host ? 'HOST' : 'GAME'}] ${error ? '[ERROR]' : '[MESSAGE]'} $line");
@@ -267,12 +282,7 @@ class _LaunchButtonState extends State<LaunchButton> {
gameProcess.stdError.listen((line) => onGameOutput(line, true));
gameProcess.exitCode.then((_) async {
final instance = host ? _hostingController.instance.value : _gameController.instance.value;
if(instance == null) {
log("[${host ? 'HOST' : 'GAME'}] Called exit code, but the game process is no longer running");
return;
}
log("[${host ? 'HOST' : 'GAME'}] Called exit code(launched: ${instance.launched}): stop signal");
log("[${host ? 'HOST' : 'GAME'}] Called exit code(launched: ${instance?.launched}): stop signal");
_onStop(
reason: _StopReason.exitCode,
host: host
@@ -289,7 +299,10 @@ class _LaunchButtonState extends State<LaunchButton> {
final process = await startProcess(
executable: file,
useTempBatch: false,
name: "${version.name}-${basenameWithoutExtension(file.path)}"
name: "${version.content}-${basenameWithoutExtension(file.path)}",
environment: {
"OPENSSL_ia32cap": "~0x20000000"
}
);
final pid = process.pid;
suspend(pid);
@@ -304,7 +317,7 @@ class _LaunchButtonState extends State<LaunchButton> {
try {
final windowManager = VirtualDesktopManager.getInstance();
_virtualDesktop = windowManager.createDesktop();
windowManager.setDesktopName(_virtualDesktop!, "${version.name} Server (Reboot Launcher)");
windowManager.setDesktopName(_virtualDesktop!, "${version.content} Server (Reboot Launcher)");
var success = false;
try {
success = await windowManager.moveWindowToDesktop(
@@ -391,7 +404,7 @@ class _LaunchButtonState extends State<LaunchButton> {
void _onGameClientInjected() {
_gameClientInfoBar?.close();
showInfoBar(
showRebootInfoBar(
translations.gameClientStarted,
severity: InfoBarSeverity.success,
duration: infoBarLongDuration
@@ -399,10 +412,15 @@ class _LaunchButtonState extends State<LaunchButton> {
}
Future<void> _onGameServerInjected() async {
_gameServerInfoBar?.close();
final theme = FluentTheme.of(appKey.currentContext!);
if(_gameServerInfoBar != null) {
_gameServerInfoBar?.close();
}else {
_gameClientInfoBar?.close();
}
final theme = FluentTheme.of(appNavigatorKey.currentContext!);
try {
_gameServerInfoBar = showInfoBar(
_gameServerInfoBar = showRebootInfoBar(
translations.waitingForGameServer,
loading: true,
duration: null
@@ -414,17 +432,17 @@ class _LaunchButtonState extends State<LaunchButton> {
);
_gameServerInfoBar?.close();
if (!localPingResult) {
showInfoBar(
showRebootInfoBar(
translations.gameServerStartWarning,
severity: InfoBarSeverity.error,
duration: infoBarLongDuration
);
return;
}
_backendController.joinLocalHost();
_backendController.joinLocalhost();
final accessible = await _checkGameServer(theme, gameServerPort);
if (!accessible) {
showInfoBar(
showRebootInfoBar(
translations.gameServerStartLocalWarning,
severity: InfoBarSeverity.warning,
duration: infoBarLongDuration
@@ -436,7 +454,7 @@ class _LaunchButtonState extends State<LaunchButton> {
_gameController.username.text,
_hostingController.instance.value!.versionName,
);
showInfoBar(
showRebootInfoBar(
translations.gameServerStarted,
severity: InfoBarSeverity.success,
duration: infoBarLongDuration
@@ -448,7 +466,7 @@ class _LaunchButtonState extends State<LaunchButton> {
Future<bool> _checkGameServer(FluentThemeData theme, String gameServerPort) async {
try {
_gameServerInfoBar = showInfoBar(
_gameServerInfoBar = showRebootInfoBar(
translations.checkingGameServer,
loading: true,
duration: null
@@ -464,12 +482,10 @@ class _LaunchButtonState extends State<LaunchButton> {
"$publicIp:$gameServerPort",
timeout: const Duration(days: 365)
);
_gameServerInfoBar = showInfoBar(
_gameServerInfoBar = showRebootInfoBar(
translations.checkGameServerFixMessage(gameServerPort),
action: Button(
onPressed: () async {
pageIndex.value = RebootPageType.info.index;
},
onPressed: () => launchUrlString("https://github.com/Auties00/reboot_launcher/blob/master/documentation/$currentLocale/PortForwarding.md"),
child: Text(translations.checkGameServerFixAction),
),
severity: InfoBarSeverity.warning,
@@ -486,7 +502,6 @@ class _LaunchButtonState extends State<LaunchButton> {
if(host == null) {
await _operation?.cancel();
_operation = null;
await _backendController.worker?.cancel();
}
host = host ?? widget.host;
@@ -542,14 +557,14 @@ class _LaunchButtonState extends State<LaunchButton> {
case _StopReason.normal:
break;
case _StopReason.missingVersionError:
showInfoBar(
showRebootInfoBar(
translations.missingVersionError,
severity: InfoBarSeverity.error,
duration: infoBarLongDuration,
);
break;
case _StopReason.missingExecutableError:
showInfoBar(
showRebootInfoBar(
translations.missingExecutableError,
severity: InfoBarSeverity.error,
duration: infoBarLongDuration,
@@ -557,7 +572,7 @@ class _LaunchButtonState extends State<LaunchButton> {
break;
case _StopReason.exitCode:
if(instance != null && !instance.launched) {
showInfoBar(
showRebootInfoBar(
translations.corruptedVersionError,
severity: InfoBarSeverity.error,
duration: infoBarLongDuration,
@@ -565,28 +580,39 @@ class _LaunchButtonState extends State<LaunchButton> {
}
break;
case _StopReason.corruptedVersionError:
showInfoBar(
showRebootInfoBar(
translations.corruptedVersionError,
severity: InfoBarSeverity.error,
duration: infoBarLongDuration,
);
break;
case _StopReason.corruptedDllError:
showInfoBar(
translations.corruptedDllError(error!),
showRebootInfoBar(
translations.corruptedDllError(error ?? translations.unknownError),
severity: InfoBarSeverity.error,
duration: infoBarLongDuration,
);
break;
case _StopReason.missingCustomDllError:
showRebootInfoBar(
translations.missingCustomDllError(error!),
severity: InfoBarSeverity.error,
duration: infoBarLongDuration,
);
break;
case _StopReason.tokenError:
showInfoBar(
translations.tokenError(instance?.injectedDlls.map((element) => element.name).join(", ") ?? "none"),
showRebootInfoBar(
translations.tokenError(instance?.injectedDlls.map((element) => element.name).join(", ") ?? translations.none),
severity: InfoBarSeverity.error,
duration: infoBarLongDuration,
action: Button(
onPressed: () => launchUrl(launcherLogFile.uri),
child: Text(translations.openLog),
)
);
break;
case _StopReason.unknownError:
showInfoBar(
showRebootInfoBar(
translations.unknownFortniteError(error ?? translations.unknownError),
severity: InfoBarSeverity.error,
duration: infoBarLongDuration,
@@ -610,7 +636,8 @@ class _LaunchButtonState extends State<LaunchButton> {
if(dllPath == null) {
log("[${hosting ? 'HOST' : 'GAME'}] The file doesn't exist");
_onStop(
reason: _StopReason.corruptedDllError,
reason: _StopReason.missingCustomDllError,
error: injectable.name,
host: hosting
);
return;
@@ -631,33 +658,62 @@ class _LaunchButtonState extends State<LaunchButton> {
}
}
Future<File?> _getDllFileOrStop(InjectableDll injectable, bool host) async {
Future<File?> _getDllFileOrStop(InjectableDll injectable, bool host, [bool isRetry = false]) async {
log("[${host ? 'HOST' : 'GAME'}] Checking dll ${injectable}...");
final path = injectable.path;
log("[${host ? 'HOST' : 'GAME'}] Path: $path");
final file = File(path);
final (file, customDll) = _settingsController.getInjectableData(injectable);
log("[${host ? 'HOST' : 'GAME'}] Path: ${file.path}, custom: $customDll");
if(await file.exists()) {
log("[${host ? 'HOST' : 'GAME'}] Path exists");
return file;
}
log("[${host ? 'HOST' : 'GAME'}] Path doesn't exist");
if(customDll || isRetry) {
log("[${host ? 'HOST' : 'GAME'}] Custom dll -> no recovery");
return null;
}
log("[${host ? 'HOST' : 'GAME'}] Path does not exist, downloading critical dll again...");
await downloadCriticalDllInteractive(path);
await _settingsController.downloadCriticalDllInteractive(file.path);
log("[${host ? 'HOST' : 'GAME'}] Downloaded dll again, retrying check...");
return _getDllFileOrStop(injectable, host);
return _getDllFileOrStop(injectable, host, true);
}
InfoBarEntry _showLaunchingGameServerWidget() => _gameServerInfoBar = showInfoBar(
translations.launchingHeadlessServer,
InfoBarEntry _showLaunchingGameServerWidget() => _gameServerInfoBar = showRebootInfoBar(
translations.launchingGameServer,
loading: true,
duration: null
);
InfoBarEntry _showLaunchingGameClientWidget() => _gameClientInfoBar = showInfoBar(
translations.launchingGameClient,
InfoBarEntry _showLaunchingGameClientWidget(FortniteVersion version, GameServerType hostType, bool linkedHosting) {
return _gameClientInfoBar = showRebootInfoBar(
linkedHosting ? translations.launchingGameClientAndServer : translations.launchingGameClientOnly,
loading: true,
duration: null
duration: null,
action: Obx(() {
if(_hostingController.started.value || linkedHosting) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(
bottom: 2.0
),
child: Button(
onPressed: () async {
_backendController.joinLocalhost();
if(!_hostingController.started.value) {
_gameController.instance.value?.child = await _startMatchMakingServer(version, false, hostType, true);
_gameClientInfoBar?.close();
_showLaunchingGameClientWidget(version, hostType, true);
}
},
child: Text(translations.startGameServer),
),
);
})
);
}
}
enum _StopReason {
@@ -665,6 +721,7 @@ enum _StopReason {
missingVersionError,
missingExecutableError,
corruptedVersionError,
missingCustomDllError,
corruptedDllError,
backendError,
matchmakerError,

View File

@@ -2,11 +2,12 @@ import 'package:fluent_ui/fluent_ui.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/controller/settings_controller.dart';
import 'package:reboot_launcher/src/dialog/implementation/profile.dart';
import 'package:reboot_launcher/src/messenger/abstract/overlay.dart';
import 'package:reboot_launcher/src/messenger/implementation/profile.dart';
class ProfileWidget extends StatefulWidget {
const ProfileWidget({Key? key}) : super(key: key);
final GlobalKey<OverlayTargetState> overlayKey;
const ProfileWidget({required this.overlayKey});
@override
State<ProfileWidget> createState() => _ProfileWidgetState();
@@ -14,14 +15,13 @@ class ProfileWidget extends StatefulWidget {
class _ProfileWidgetState extends State<ProfileWidget> {
final GameController _gameController = Get.find<GameController>();
final SettingsController _settingsController = Get.find<SettingsController>();
@override
Widget build(BuildContext context) => Obx(() {
final firstRun = _settingsController.firstRun.value;
return HoverButton(
Widget build(BuildContext context) => OverlayTarget(
key: widget.overlayKey,
child: HoverButton(
margin: const EdgeInsets.all(8.0),
onPressed: firstRun ? null : () async {
onPressed: () async {
if(await showProfileForm(context)) {
setState(() {});
}
@@ -78,8 +78,8 @@ class _ProfileWidgetState extends State<ProfileWidget> {
),
),
)
);
});
),
);
String get _username {
var username = _gameController.username.text;

View File

@@ -4,7 +4,7 @@ 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/dialog/implementation/server.dart';
import 'package:reboot_launcher/src/messenger/implementation/server.dart';
import 'package:reboot_launcher/src/util/translations.dart';
class ServerButton extends StatefulWidget {

View File

@@ -2,12 +2,13 @@ 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/dialog/abstract/dialog.dart';
import 'package:reboot_launcher/src/messenger/abstract/dialog.dart';
import 'package:reboot_launcher/src/messenger/abstract/overlay.dart';
import 'package:reboot_launcher/src/util/translations.dart';
class ServerTypeSelector extends StatefulWidget {
const ServerTypeSelector({Key? key})
: super(key: key);
final Key overlayKey;
const ServerTypeSelector({required this.overlayKey});
@override
State<ServerTypeSelector> createState() => _ServerTypeSelectorState();
@@ -18,12 +19,16 @@ class _ServerTypeSelectorState extends State<ServerTypeSelector> {
@override
Widget build(BuildContext context) {
return Obx(() => DropDownButton(
onOpen: () => inDialog = true,
leading: Text(_controller.type.value.label),
items: ServerType.values
.map((type) => _createItem(type))
.toList()
return Obx(() => OverlayTarget(
key: widget.overlayKey,
child: DropDownButton(
onOpen: () => inDialog = true,
onClose: () => inDialog = false,
leading: Text(_controller.type.value.label),
items: ServerType.values
.map((type) => _createItem(type))
.toList()
),
));
}

View File

@@ -1,11 +1,12 @@
import 'package:fluent_ui/fluent_ui.dart' hide FluentIcons;
import 'package:fluentui_system_icons/fluentui_system_icons.dart';
import 'package:reboot_launcher/src/messenger/abstract/overlay.dart';
import 'package:reboot_launcher/src/page/pages.dart';
import 'package:skeletons/skeletons.dart';
class SettingTile extends StatelessWidget {
class SettingTile extends StatefulWidget {
static const double kDefaultHeight = 80.0;
static const double kDefaultContentWidth = 200.0;
static const double kDefaultHeaderHeight = 72;
final void Function()? onPressed;
final Icon icon;
@@ -13,28 +14,38 @@ class SettingTile extends StatelessWidget {
final Text? subtitle;
final Widget? content;
final double? contentWidth;
final Key? overlayKey;
final List<Widget>? children;
const SettingTile({
super.key,
this.onPressed,
required this.icon,
required this.title,
required this.subtitle,
this.content,
this.contentWidth = kDefaultContentWidth,
this.overlayKey,
this.children
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(
bottom: 4.0
),
child: HoverButton(
onPressed: _buildOnPressed(context),
builder: (context, states) => Container(
height: 80,
State<SettingTile> createState() => SettingTileState();
}
class SettingTileState extends State<SettingTile> {
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(
bottom: 4.0
),
child: HoverButton(
onPressed: _buildOnPressed(),
builder: (context, states) => ConstrainedBox(
constraints: BoxConstraints(
minHeight: SettingTile.kDefaultHeight
),
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: ButtonThemeData.uncheckedInputColor(
@@ -42,76 +53,92 @@ class SettingTile extends StatelessWidget {
states,
transparentWhenNone: true,
),
borderRadius: BorderRadius.all(Radius.circular(4.0))
borderRadius: BorderRadius.all(Radius.circular(6.0))
),
child: Card(
borderRadius: const BorderRadius.all(
Radius.circular(4.0)
),
child: Padding(
padding: const EdgeInsetsDirectional.symmetric(
horizontal: 12.0,
vertical: 6.0
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
icon,
const SizedBox(width: 16.0),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
title == null ? _skeletonTitle : title!,
subtitle == null ? _skeletonSubtitle : subtitle!,
],
),
const Spacer(),
_trailing
],
),
)
)
child: _buildBody()
),
),
),
);
Card _buildBody() {
return Card(
borderRadius: const BorderRadius.all(
Radius.circular(6.0)
),
child: Padding(
padding: const EdgeInsetsDirectional.symmetric(
horizontal: 12.0
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if(widget.overlayKey != null)
OverlayTarget(
key: widget.overlayKey,
child: widget.icon,
)
else
widget.icon,
const SizedBox(width: 16.0),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
widget.title == null ? _skeletonTitle : widget.title!,
widget.subtitle == null ? _skeletonSubtitle : widget.subtitle!,
],
),
const Spacer(),
_trailing
],
),
)
);
}
void Function()? _buildOnPressed(BuildContext context) {
if(onPressed != null) {
return onPressed;
void Function()? _buildOnPressed() {
if(widget.onPressed != null) {
return widget.onPressed;
}
final children = this.children;
final children = this.widget.children;
if (children == null) {
return null;
}
return () async {
await Navigator.of(context).push(PageRouteBuilder(
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
settings: RouteSettings(
name: title?.data
),
pageBuilder: (context, incoming, outgoing) => ListView.builder(
itemCount: children.length,
itemBuilder: (context, index) => children[index]
)
));
WidgetsBinding.instance.addPostFrameCallback((timeStamp) => pageIndex.value = pageIndex.value);
};
return () => openNestedPage();
}
Future<void> openNestedPage() async {
final children = this.widget.children;
if (children == null) {
return;
}
await Navigator.of(context).push(PageRouteBuilder(
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
settings: RouteSettings(
name: widget.title?.data
),
pageBuilder: (context, incoming, outgoing) => ListView.builder(
itemCount: children.length,
itemBuilder: (context, index) => children[index]
)
));
WidgetsBinding.instance.addPostFrameCallback((timeStamp) => pageIndex.value = pageIndex.value);
}
Widget get _trailing {
final hasContent = content != null;
final hasChildren = children?.isNotEmpty == true;
final hasListener = onPressed != null;
final hasContent = widget.content != null;
final hasChildren = widget.children?.isNotEmpty == true;
final hasListener = widget.onPressed != null;
if(hasContent && hasChildren) {
return Row(
children: [
SizedBox(
width: contentWidth,
child: content
width: widget.contentWidth,
child: widget.content
),
const SizedBox(width: 16.0),
Icon(
@@ -123,8 +150,8 @@ class SettingTile extends StatelessWidget {
if (hasContent) {
return SizedBox(
width: contentWidth,
child: content
width: widget.contentWidth,
child: widget.content
);
}

View File

@@ -1,24 +0,0 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:get/get.dart';
import 'package:reboot_launcher/src/controller/game_controller.dart';
import 'package:reboot_launcher/src/util/checks.dart';
import 'package:reboot_launcher/src/util/translations.dart';
class VersionNameInput extends StatelessWidget {
final GameController _gameController = Get.find<GameController>();
final TextEditingController controller;
VersionNameInput({Key? key, required this.controller}) : super(key: key);
@override
Widget build(BuildContext context) => InfoLabel(
label: translations.versionName,
child: TextFormBox(
controller: controller,
placeholder: translations.versionNameLabel,
autofocus: true,
validator: (version) => checkVersion(version, _gameController.versions.value),
autovalidateMode: AutovalidateMode.onUserInteraction
),
);
}

View File

@@ -1,31 +1,25 @@
import 'dart:async';
import 'dart:io';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:fluent_ui/fluent_ui.dart' hide FluentIcons;
import 'package:fluentui_system_icons/fluentui_system_icons.dart';
import 'package:flutter/gestures.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/dialog/abstract/dialog.dart';
import 'package:reboot_launcher/src/dialog/abstract/dialog_button.dart';
import 'package:reboot_launcher/src/dialog/abstract/info_bar.dart';
import 'package:reboot_launcher/src/util/checks.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/messenger/implementation/version.dart';
import 'package:reboot_launcher/src/util/translations.dart';
import 'package:reboot_launcher/src/widget/add_local_version.dart';
import 'package:reboot_launcher/src/widget/add_server_version.dart';
import 'package:reboot_launcher/src/widget/file_selector.dart';
import 'package:reboot_launcher/src/widget/setting_tile.dart';
import 'package:url_launcher/url_launcher.dart';
class VersionSelector extends StatefulWidget {
const VersionSelector({Key? key}) : super(key: key);
static Future<void> openDownloadDialog() => showAppDialog<bool>(
builder: (context) => const AddServerVersion(),
);
static Future<void> openAddDialog() => showAppDialog<bool>(
builder: (context) => const AddLocalVersion(),
static Future<void> openDownloadDialog({bool closable = true}) => showRebootDialog<bool>(
builder: (context) => AddVersionDialog(
closable: closable,
),
dismissWithEsc: closable
);
@override
@@ -48,7 +42,7 @@ class _VersionSelectorState extends State<VersionSelector> {
onOpen: () => inDialog = true,
onClose: () => inDialog = false,
leading: Text(
_gameController.selectedVersion?.name ?? translations.selectVersion,
_gameController.selectedVersion?.content.toString() ?? translations.selectVersion,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
@@ -58,30 +52,6 @@ class _VersionSelectorState extends State<VersionSelector> {
);
});
List<MenuFlyoutItem> _createSelectorItems(BuildContext context) {
final items = _gameController.versions.value
.map((version) => _createVersionItem(context, version))
.toList();
items.add(MenuFlyoutItem(
text: Text(translations.addLocalBuildContent),
onPressed: VersionSelector.openAddDialog
));
items.add(MenuFlyoutItem(
text: Text(translations.downloadBuildContent),
onPressed: VersionSelector.openDownloadDialog
));
return items;
}
MenuFlyoutItem _createVersionItem(BuildContext context, FortniteVersion version) => MenuFlyoutItem(
text: _createOptionsMenu(
version: version,
close: true,
child: Text(version.name),
),
onPressed: () => _gameController.selectedVersion = version
);
Widget _createOptionsMenu({required FortniteVersion? version, required bool close, required Widget child}) => Listener(
onPointerDown: (event) async {
if (event.kind != PointerDeviceKind.mouse || event.buttons != kSecondaryMouseButton) {
@@ -104,13 +74,64 @@ class _VersionSelectorState extends State<VersionSelector> {
child: child
);
List<MenuFlyoutItem> _createSelectorItems(BuildContext context) {
final items = _gameController.versions.value
.map((version) => _createVersionItem(context, version))
.toList();
items.add(MenuFlyoutItem(
trailing: Padding(
padding: const EdgeInsets.all(8.0),
child: Icon(
FluentIcons.add_24_regular,
size: 12
),
),
text: Text(translations.addVersion),
onPressed: VersionSelector.openDownloadDialog
));
return items;
}
MenuFlyoutItem _createVersionItem(BuildContext context, FortniteVersion version) => MenuFlyoutItem(
text: Listener(
onPointerDown: (event) async {
if (event.kind != PointerDeviceKind.mouse || event.buttons != kSecondaryMouseButton) {
return;
}
await _openVersionOptions(version);
},
child: Text(version.content.toString())
),
trailing: IconButton(
onPressed: () => _openVersionOptions(version),
icon: Icon(
FluentIcons.more_vertical_24_regular
)
),
onPressed: () => _gameController.selectedVersion = version
);
Future<void> _openVersionOptions(FortniteVersion version) async {
final result = await _flyoutController.showFlyout<_ContextualOption?>(
builder: (context) => MenuFlyout(
items: _ContextualOption.values
.map((entry) => _createOption(context, entry))
.toList()
),
barrierDismissible: true,
barrierColor: Colors.transparent
);
_handleResult(result, version, true);
}
void _handleResult(_ContextualOption? result, FortniteVersion version, bool close) async {
if(!mounted){
return;
}
switch (result) {
case _ContextualOption.openExplorer:
if(!mounted){
return;
}
if(close) {
Navigator.of(context).pop();
}
@@ -118,23 +139,8 @@ class _VersionSelectorState extends State<VersionSelector> {
launchUrl(version.location.uri)
.onError((error, stackTrace) => _onExplorerError());
break;
case _ContextualOption.modify:
if(!mounted){
return;
}
if(close) {
Navigator.of(context).pop();
}
await _openRenameDialog(context, version);
break;
case _ContextualOption.delete:
if(!mounted){
return;
}
var result = await _openDeleteDialog(context, version) ?? false;
final result = await _openDeleteDialog(context, version) ?? false;
if(!mounted || !result){
return;
}
@@ -149,25 +155,25 @@ class _VersionSelectorState extends State<VersionSelector> {
}
break;
default:
case null:
break;
}
}
MenuFlyoutItem _createOption(BuildContext context, _ContextualOption entry) {
return MenuFlyoutItem(
text: Text(entry.name),
text: Text(entry.translatedName),
onPressed: () => Navigator.of(context).pop(entry)
);
}
bool _onExplorerError() {
showInfoBar(translations.missingVersion);
showRebootInfoBar(translations.missingVersion);
return false;
}
Future<bool?> _openDeleteDialog(BuildContext context, FortniteVersion version) {
return showAppDialog<bool>(
return showRebootDialog<bool>(
builder: (context) => ContentDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
@@ -189,87 +195,32 @@ class _VersionSelectorState extends State<VersionSelector> {
],
),
actions: [
Button(
onPressed: () => Navigator.of(context).pop(false),
child: Text(translations.deleteVersionCancel),
DialogButton(
type: ButtonType.secondary,
onTap: () => Navigator.of(context).pop(false),
text: translations.deleteVersionCancel
),
Button(
onPressed: () => Navigator.of(context).pop(true),
child: Text(translations.deleteVersionConfirm),
DialogButton(
type: ButtonType.primary,
onTap: () => Navigator.of(context).pop(true),
text: translations.deleteVersionConfirm
)
],
)
);
}
Future<String?> _openRenameDialog(BuildContext context, FortniteVersion version) {
var nameController = TextEditingController(text: version.name);
var pathController = TextEditingController(text: version.location.path);
return showAppDialog<String?>(
builder: (context) => FormDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InfoLabel(
label: translations.versionName,
child: TextFormBox(
controller: nameController,
placeholder: translations.newVersionNameLabel,
autofocus: true,
validator: (text) => checkChangeVersion(text)
)
),
const SizedBox(
height: 16.0
),
FileSelector(
placeholder: translations.newVersionNameLabel,
windowTitle: translations.gameFolderPlaceWindowTitle,
label: translations.gameFolderLabel,
controller: pathController,
validator: checkGameFolder,
folder: true
),
const SizedBox(height: 8.0),
],
),
buttons: [
DialogButton(
type: ButtonType.secondary
),
DialogButton(
text: translations.newVersionNameConfirm,
type: ButtonType.primary,
onTap: () {
Navigator.of(context).pop();
_gameController.updateVersion(version, (version) {
version.name = nameController.text;
version.location = Directory(pathController.text);
});
},
)
]
)
);
}
}
enum _ContextualOption {
openExplorer,
modify,
delete
}
delete;
extension _ContextualOptionExtension on _ContextualOption {
String get name {
return this == _ContextualOption.openExplorer ? translations.openInExplorer
: this == _ContextualOption.modify ? translations.modify
: translations.delete;
String get translatedName {
switch(this) {
case _ContextualOption.openExplorer:
return translations.openInExplorer;
case _ContextualOption.delete:
return translations.delete;
}
}
}
}

View File

@@ -1,10 +1,13 @@
import 'package:fluent_ui/fluent_ui.dart' hide FluentIcons;
import 'package:fluentui_system_icons/fluentui_system_icons.dart';
import 'package:reboot_launcher/src/messenger/abstract/overlay.dart';
import 'package:reboot_launcher/src/util/translations.dart';
import 'package:reboot_launcher/src/widget/setting_tile.dart';
import 'package:reboot_launcher/src/widget/version_selector.dart';
SettingTile get versionSelectSettingTile => SettingTile(
SettingTile buildVersionSelector({
required GlobalKey<OverlayTargetState> key
}) => SettingTile(
icon: Icon(
FluentIcons.play_24_regular
),
@@ -15,6 +18,9 @@ SettingTile get versionSelectSettingTile => SettingTile(
constraints: BoxConstraints(
minWidth: SettingTile.kDefaultContentWidth,
),
child: const VersionSelector()
child: OverlayTarget(
key: key,
child: const VersionSelector(),
)
)
);