mirror of
https://github.com/Auties00/Reboot-Launcher.git
synced 2026-01-14 03:32:23 +01:00
Reboot v3
This commit is contained in:
26
lib/src/ui/controller/build_controller.dart
Normal file
26
lib/src/ui/controller/build_controller.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_launcher/src/model/fortnite_build.dart';
|
||||
|
||||
class BuildController extends GetxController {
|
||||
List<FortniteBuild>? builds;
|
||||
FortniteBuild? _selectedBuild;
|
||||
final List<Function()> _listeners;
|
||||
late RxBool cancelledDownload;
|
||||
|
||||
BuildController() : _listeners = [] {
|
||||
cancelledDownload = RxBool(false);
|
||||
}
|
||||
|
||||
FortniteBuild get selectedBuild => _selectedBuild ?? builds!.elementAt(0);
|
||||
|
||||
set selectedBuild(FortniteBuild build) {
|
||||
_selectedBuild = build;
|
||||
for (var listener in _listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
void addOnBuildChangedListener(Function() listener) => _listeners.add(listener);
|
||||
|
||||
void removeOnBuildChangedListener() => _listeners.clear();
|
||||
}
|
||||
91
lib/src/ui/controller/game_controller.dart
Normal file
91
lib/src/ui/controller/game_controller.dart
Normal file
@@ -0,0 +1,91 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
import 'package:reboot_launcher/src/model/fortnite_version.dart';
|
||||
import 'package:reboot_launcher/src/model/game_instance.dart';
|
||||
|
||||
import '../../model/update_status.dart';
|
||||
|
||||
const String kDefaultPlayerName = "Player";
|
||||
|
||||
class GameController extends GetxController {
|
||||
late final GetStorage _storage;
|
||||
late final TextEditingController username;
|
||||
late final TextEditingController password;
|
||||
late final RxBool showPassword;
|
||||
late final TextEditingController customLaunchArgs;
|
||||
late final Rx<List<FortniteVersion>> versions;
|
||||
late final Rxn<FortniteVersion> _selectedVersion;
|
||||
late final RxBool started;
|
||||
late final Rx<UpdateStatus> updateStatus;
|
||||
GameInstance? instance;
|
||||
|
||||
GameController() {
|
||||
_storage = GetStorage("reboot_game");
|
||||
Iterable decodedVersionsJson = jsonDecode(_storage.read("versions") ?? "[]");
|
||||
var decodedVersions = decodedVersionsJson
|
||||
.map((entry) => FortniteVersion.fromJson(entry))
|
||||
.toList();
|
||||
versions = Rx(decodedVersions);
|
||||
versions.listen((data) => _saveVersions());
|
||||
var decodedSelectedVersionName = _storage.read("version");
|
||||
var decodedSelectedVersion = decodedVersions.firstWhereOrNull(
|
||||
(element) => element.name == decodedSelectedVersionName);
|
||||
_selectedVersion = Rxn(decodedSelectedVersion);
|
||||
username = TextEditingController(text: _storage.read("username") ?? kDefaultPlayerName);
|
||||
username.addListener(() => _storage.write("username", username.text));
|
||||
password = TextEditingController(text: _storage.read("password") ?? "");
|
||||
password.addListener(() => _storage.write("password", password.text));
|
||||
showPassword = RxBool(false);
|
||||
customLaunchArgs = TextEditingController(text: _storage.read("custom_launch_args" ?? ""));
|
||||
customLaunchArgs.addListener(() => _storage.write("custom_launch_args", customLaunchArgs.text));
|
||||
started = RxBool(false);
|
||||
updateStatus = Rx(UpdateStatus.waiting);
|
||||
}
|
||||
|
||||
FortniteVersion? getVersionByName(String name) {
|
||||
return versions.value.firstWhereOrNull((element) => element.name == name);
|
||||
}
|
||||
|
||||
void addVersion(FortniteVersion version) {
|
||||
var empty = versions.value.isEmpty;
|
||||
versions.update((val) => val?.add(version));
|
||||
if(empty){
|
||||
selectedVersion = version;
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
Future<void> _saveVersions() async {
|
||||
var serialized = jsonEncode(versions.value.map((entry) => entry.toJson()).toList());
|
||||
await _storage.write("versions", serialized);
|
||||
}
|
||||
|
||||
bool get hasVersions => versions.value.isNotEmpty;
|
||||
|
||||
bool get hasNoVersions => versions.value.isEmpty;
|
||||
|
||||
FortniteVersion? get selectedVersion => _selectedVersion();
|
||||
|
||||
set selectedVersion(FortniteVersion? version) {
|
||||
_selectedVersion(version);
|
||||
_storage.write("version", version?.name);
|
||||
}
|
||||
|
||||
void updateVersion(FortniteVersion version, Function(FortniteVersion) function) {
|
||||
versions.update((val) => function(version));
|
||||
}
|
||||
}
|
||||
28
lib/src/ui/controller/hosting_controller.dart
Normal file
28
lib/src/ui/controller/hosting_controller.dart
Normal file
@@ -0,0 +1,28 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
|
||||
import '../../model/game_instance.dart';
|
||||
|
||||
|
||||
const String kDefaultServerName = "Reboot Game Server";
|
||||
|
||||
class HostingController extends GetxController {
|
||||
late final GetStorage _storage;
|
||||
late final TextEditingController name;
|
||||
late final TextEditingController category;
|
||||
late final RxBool discoverable;
|
||||
late final RxBool started;
|
||||
GameInstance? instance;
|
||||
|
||||
HostingController() {
|
||||
_storage = GetStorage("reboot_hosting");
|
||||
name = TextEditingController(text: _storage.read("name") ?? kDefaultServerName);
|
||||
name.addListener(() => _storage.write("name", name.text));
|
||||
category = TextEditingController(text: _storage.read("category") ?? "");
|
||||
category.addListener(() => _storage.write("category", category.text));
|
||||
discoverable = RxBool(_storage.read("discoverable") ?? false);
|
||||
discoverable.listen((value) => _storage.write("discoverable", value));
|
||||
started = RxBool(false);
|
||||
}
|
||||
}
|
||||
78
lib/src/ui/controller/server_controller.dart
Normal file
78
lib/src/ui/controller/server_controller.dart
Normal file
@@ -0,0 +1,78 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
import 'package:jaguar/jaguar.dart';
|
||||
|
||||
import '../../model/server_type.dart';
|
||||
import '../../util/server.dart';
|
||||
|
||||
class ServerController extends GetxController {
|
||||
static const String _serverName = "127.0.0.1";
|
||||
static const String _serverPort = "3551";
|
||||
|
||||
late final GetStorage _storage;
|
||||
late final TextEditingController host;
|
||||
late final TextEditingController port;
|
||||
late final Rx<ServerType> type;
|
||||
late final RxBool warning;
|
||||
late RxBool started;
|
||||
late RxBool detached;
|
||||
HttpServer? remoteServer;
|
||||
|
||||
ServerController() {
|
||||
_storage = GetStorage("reboot_server");
|
||||
started = RxBool(false);
|
||||
type = Rx(ServerType.values.elementAt(_storage.read("type") ?? 0));
|
||||
type.listen((value) {
|
||||
host.text = _readHost();
|
||||
port.text = _readPort();
|
||||
_storage.write("type", value.index);
|
||||
if(!started.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
stop();
|
||||
});
|
||||
host = TextEditingController(text: _readHost());
|
||||
host.addListener(() => _storage.write("${type.value.id}_host", host.text));
|
||||
port = TextEditingController(text: _readPort());
|
||||
port.addListener(() => _storage.write("${type.value.id}_port", port.text));
|
||||
warning = RxBool(_storage.read("lawin_value") ?? true);
|
||||
warning.listen((value) => _storage.write("lawin_value", value));
|
||||
detached = RxBool(_storage.read("detached") ?? false);
|
||||
warning.listen((value) => _storage.write("detached", value));
|
||||
}
|
||||
|
||||
String _readHost() {
|
||||
String? value = _storage.read("${type.value.id}_host");
|
||||
return value != null && value.isNotEmpty ? value
|
||||
: type.value != ServerType.remote ? _serverName : "";
|
||||
}
|
||||
|
||||
String _readPort() {
|
||||
return _storage.read("${type.value.id}_port") ?? _serverPort;
|
||||
}
|
||||
|
||||
Future<bool> stop() async {
|
||||
started.value = false;
|
||||
try{
|
||||
switch(type()){
|
||||
case ServerType.embedded:
|
||||
stopServer();
|
||||
break;
|
||||
case ServerType.remote:
|
||||
await remoteServer?.close(force: true);
|
||||
remoteServer = null;
|
||||
break;
|
||||
case ServerType.local:
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}catch(_){
|
||||
started.value = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
79
lib/src/ui/controller/settings_controller.dart
Normal file
79
lib/src/ui/controller/settings_controller.dart
Normal file
@@ -0,0 +1,79 @@
|
||||
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
import 'package:reboot_launcher/main.dart';
|
||||
import 'package:reboot_launcher/src/util/os.dart';
|
||||
import 'package:reboot_launcher/src/util/server.dart';
|
||||
import 'dart:ui';
|
||||
|
||||
import '../../util/reboot.dart';
|
||||
|
||||
class SettingsController extends GetxController {
|
||||
static const String _kDefaultIp = "127.0.0.1";
|
||||
static const bool _kDefaultAutoUpdate = true;
|
||||
|
||||
late final GetStorage _storage;
|
||||
late final String originalDll;
|
||||
late final TextEditingController updateUrl;
|
||||
late final TextEditingController rebootDll;
|
||||
late final TextEditingController consoleDll;
|
||||
late final TextEditingController authDll;
|
||||
late final TextEditingController matchmakingIp;
|
||||
late final RxBool autoUpdate;
|
||||
late double width;
|
||||
late double height;
|
||||
late double? offsetX;
|
||||
late double? offsetY;
|
||||
late double scrollingDistance;
|
||||
|
||||
SettingsController() {
|
||||
_storage = GetStorage("reboot_settings");
|
||||
updateUrl = TextEditingController(text: _storage.read("update_url") ?? rebootDownloadUrl);
|
||||
updateUrl.addListener(() => _storage.write("update_url", updateUrl.text));
|
||||
rebootDll = _createController("reboot", "reboot.dll");
|
||||
consoleDll = _createController("console", "console.dll");
|
||||
authDll = _createController("cobalt", "cobalt.dll");
|
||||
matchmakingIp = TextEditingController(text: _storage.read("ip") ?? _kDefaultIp);
|
||||
matchmakingIp.addListener(() async {
|
||||
var text = matchmakingIp.text;
|
||||
_storage.write("ip", text);
|
||||
writeMatchmakingIp(text);
|
||||
});
|
||||
width = _storage.read("width") ?? kDefaultWindowWidth;
|
||||
height = _storage.read("height") ?? kDefaultWindowHeight;
|
||||
offsetX = _storage.read("offset_x");
|
||||
offsetY = _storage.read("offset_y");
|
||||
autoUpdate = RxBool(_storage.read("auto_update") ?? _kDefaultAutoUpdate);
|
||||
autoUpdate.listen((value) async => _storage.write("auto_update", value));
|
||||
scrollingDistance = 0.0;
|
||||
}
|
||||
|
||||
TextEditingController _createController(String key, String name) {
|
||||
var controller = TextEditingController(text: _storage.read(key) ?? _controllerDefaultPath(name));
|
||||
controller.addListener(() => _storage.write(key, controller.text));
|
||||
return controller;
|
||||
}
|
||||
|
||||
void saveWindowSize() {
|
||||
_storage.write("width", window.physicalSize.width);
|
||||
_storage.write("height", window.physicalSize.height);
|
||||
}
|
||||
|
||||
void saveWindowOffset(Offset position) {
|
||||
_storage.write("offset_x", position.dx);
|
||||
_storage.write("offset_y", position.dy);
|
||||
}
|
||||
|
||||
void reset(){
|
||||
updateUrl.text = rebootDownloadUrl;
|
||||
rebootDll.text = _controllerDefaultPath("reboot.dll");
|
||||
consoleDll.text = _controllerDefaultPath("console.dll");
|
||||
authDll.text = _controllerDefaultPath("cobalt.dll");
|
||||
matchmakingIp.text = _kDefaultIp;
|
||||
writeMatchmakingIp(_kDefaultIp);
|
||||
autoUpdate.value = _kDefaultAutoUpdate;
|
||||
}
|
||||
|
||||
String _controllerDefaultPath(String name) => "${assetsDirectory.path}\\dlls\\$name";
|
||||
}
|
||||
6
lib/src/ui/controller/update_controller.dart
Normal file
6
lib/src/ui/controller/update_controller.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
|
||||
final GetStorage _storage = GetStorage("reboot_update");
|
||||
|
||||
int? get updateTime => _storage.read("last_update_v2");
|
||||
set updateTime(int? updateTime) => _storage.write("last_update_v2", updateTime);
|
||||
78
lib/src/ui/dialog/add_local_version.dart
Normal file
78
lib/src/ui/dialog/add_local_version.dart
Normal file
@@ -0,0 +1,78 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_launcher/src/model/fortnite_version.dart';
|
||||
import 'package:reboot_launcher/src/ui/controller/game_controller.dart';
|
||||
|
||||
import '../../util/checks.dart';
|
||||
import '../widget/shared/file_selector.dart';
|
||||
import '../widget/shared/smart_check_box.dart';
|
||||
import 'dialog.dart';
|
||||
import 'dialog_button.dart';
|
||||
|
||||
class AddLocalVersion extends StatelessWidget {
|
||||
final GameController _gameController = Get.find<GameController>();
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
final TextEditingController _gamePathController = TextEditingController();
|
||||
|
||||
AddLocalVersion({Key? key})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FormDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: double.infinity,
|
||||
child: InfoBar(
|
||||
title: Text("Local builds are not guaranteed to work"),
|
||||
severity: InfoBarSeverity.info
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(
|
||||
height: 16.0
|
||||
),
|
||||
|
||||
TextFormBox(
|
||||
controller: _nameController,
|
||||
header: "Name",
|
||||
placeholder: "Type the version's name",
|
||||
autofocus: true,
|
||||
validator: (text) => checkVersion(text, _gameController.versions.value)
|
||||
),
|
||||
|
||||
FileSelector(
|
||||
label: "Path",
|
||||
placeholder: "Type the game folder",
|
||||
windowTitle: "Select game folder",
|
||||
controller: _gamePathController,
|
||||
validator: checkGameFolder,
|
||||
folder: true
|
||||
)
|
||||
],
|
||||
),
|
||||
buttons: [
|
||||
DialogButton(
|
||||
type: ButtonType.secondary
|
||||
),
|
||||
|
||||
DialogButton(
|
||||
text: "Save",
|
||||
type: ButtonType.primary,
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
_gameController.addVersion(FortniteVersion(
|
||||
name: _nameController.text,
|
||||
location: Directory(_gamePathController.text)
|
||||
));
|
||||
},
|
||||
)
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
328
lib/src/ui/dialog/add_server_version.dart
Normal file
328
lib/src/ui/dialog/add_server_version.dart
Normal file
@@ -0,0 +1,328 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:async/async.dart';
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_launcher/src/model/fortnite_version.dart';
|
||||
import 'package:reboot_launcher/src/util/error.dart';
|
||||
import 'package:reboot_launcher/src/util/os.dart';
|
||||
import 'package:reboot_launcher/src/util/build.dart';
|
||||
import 'package:reboot_launcher/src/ui/controller/game_controller.dart';
|
||||
import 'package:universal_disk_space/universal_disk_space.dart';
|
||||
|
||||
import '../../util/checks.dart';
|
||||
import '../controller/build_controller.dart';
|
||||
import '../widget/home/build_selector.dart';
|
||||
import '../widget/home/version_name_input.dart';
|
||||
import '../widget/shared/file_selector.dart';
|
||||
import 'dialog.dart';
|
||||
import 'dialog_button.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();
|
||||
|
||||
late DiskSpace _diskSpace;
|
||||
late Future _fetchFuture;
|
||||
late Future _diskFuture;
|
||||
|
||||
DownloadStatus _status = DownloadStatus.form;
|
||||
String _timeLeft = "00:00:00";
|
||||
double _downloadProgress = 0;
|
||||
CancelableOperation? _manifestDownloadProcess;
|
||||
CancelableOperation? _driveDownloadOperation;
|
||||
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());
|
||||
_buildController.addOnBuildChangedListener(() => _updateFormDefaults());
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pathController.dispose();
|
||||
_nameController.dispose();
|
||||
_buildController.removeOnBuildChangedListener();
|
||||
_onDisposed();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onDisposed() {
|
||||
if (_status != DownloadStatus.downloading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_manifestDownloadProcess != null) {
|
||||
_manifestDownloadProcess?.cancel();
|
||||
_buildController.cancelledDownload(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_driveDownloadOperation == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_driveDownloadOperation!.cancel();
|
||||
_buildController.cancelledDownload(true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
switch(_status){
|
||||
case DownloadStatus.form:
|
||||
return _createFormDialog();
|
||||
case DownloadStatus.downloading:
|
||||
return GenericDialog(
|
||||
header: _createDownloadBody(),
|
||||
buttons: _createCloseButton()
|
||||
);
|
||||
case DownloadStatus.extracting:
|
||||
return GenericDialog(
|
||||
header: _createExtractingBody(),
|
||||
buttons: _createCloseButton()
|
||||
);
|
||||
case DownloadStatus.error:
|
||||
return ErrorDialog(
|
||||
exception: _error ?? Exception("unknown error"),
|
||||
stackTrace: _stackTrace,
|
||||
errorMessageBuilder: (exception) => "Cannot download version: $exception"
|
||||
);
|
||||
case DownloadStatus.done:
|
||||
return const InfoDialog(
|
||||
text: "The download was completed successfully!",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<DialogButton> _createFormButtons() {
|
||||
return [
|
||||
DialogButton(type: ButtonType.secondary),
|
||||
DialogButton(
|
||||
text: "Download",
|
||||
type: ButtonType.primary,
|
||||
onTap: () => _startDownload(context),
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
void _startDownload(BuildContext context) async {
|
||||
try {
|
||||
setState(() => _status = DownloadStatus.downloading);
|
||||
var future = downloadArchiveBuild(
|
||||
_buildController.selectedBuild.link,
|
||||
Directory(_pathController.text),
|
||||
_onDownloadProgress,
|
||||
_onUnrar
|
||||
);
|
||||
future.then((value) => _onDownloadComplete());
|
||||
_manifestDownloadProcess = CancelableOperation.fromFuture(future);
|
||||
} catch (exception, stackTrace) {
|
||||
_onDownloadError(exception, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
void _onUnrar() {
|
||||
setState(() => _status = DownloadStatus.extracting);
|
||||
}
|
||||
|
||||
Future<void> _onDownloadComplete() async {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_status = DownloadStatus.done;
|
||||
_gameController.addVersion(FortniteVersion(
|
||||
name: _nameController.text,
|
||||
location: Directory(_pathController.text)
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
void _onDownloadError(Object? error, StackTrace? stackTrace) {
|
||||
print("Error");
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_status = DownloadStatus.error;
|
||||
_error = error;
|
||||
_stackTrace = stackTrace;
|
||||
});
|
||||
}
|
||||
|
||||
void _onDownloadProgress(double progress, String timeLeft) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_status = DownloadStatus.downloading;
|
||||
_timeLeft = timeLeft;
|
||||
_downloadProgress = progress;
|
||||
});
|
||||
}
|
||||
|
||||
Widget _createDownloadBody() => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
"Downloading...",
|
||||
style: FluentTheme.maybeOf(context)?.typography.body,
|
||||
textAlign: TextAlign.start,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"${_downloadProgress.round()}%",
|
||||
style: FluentTheme.maybeOf(context)?.typography.body,
|
||||
),
|
||||
|
||||
Text(
|
||||
"Time left: $_timeLeft",
|
||||
style: FluentTheme.maybeOf(context)?.typography.body,
|
||||
)
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ProgressBar(value: _downloadProgress.toDouble())
|
||||
),
|
||||
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
)
|
||||
],
|
||||
);
|
||||
|
||||
Widget _createExtractingBody() => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
"Extracting...",
|
||||
style: FluentTheme.maybeOf(context)?.typography.body,
|
||||
textAlign: TextAlign.start,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
|
||||
const SizedBox(
|
||||
width: double.infinity,
|
||||
child: ProgressBar()
|
||||
),
|
||||
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
)
|
||||
],
|
||||
);
|
||||
|
||||
Widget _createFormDialog() {
|
||||
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: "Fetching builds and disks...",
|
||||
onStop: () => Navigator.of(context).pop()
|
||||
);
|
||||
}
|
||||
|
||||
return FormDialog(
|
||||
content: _createFormBody(),
|
||||
buttons: _createFormButtons()
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Widget _createFormBody() {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const BuildSelector(),
|
||||
const SizedBox(height: 20.0),
|
||||
VersionNameInput(controller: _nameController),
|
||||
FileSelector(
|
||||
label: "Path",
|
||||
placeholder: "Type the download destination",
|
||||
windowTitle: "Select download destination",
|
||||
controller: _pathController,
|
||||
validator: checkDownloadDestination,
|
||||
folder: true
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<DialogButton> _createCloseButton() {
|
||||
return [
|
||||
DialogButton(
|
||||
text: "Stop",
|
||||
type: ButtonType.only
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _updateFormDefaults() async {
|
||||
if(_diskSpace.disks.isEmpty){
|
||||
return;
|
||||
}
|
||||
|
||||
await _fetchFuture;
|
||||
var bestDisk = _diskSpace.disks
|
||||
.reduce((first, second) => first.availableSpace > second.availableSpace ? first : second);
|
||||
_pathController.text = "${bestDisk.devicePath}\\FortniteBuilds\\Fortnite "
|
||||
"${_buildController.selectedBuild.version.toString()}";
|
||||
_nameController.text = _buildController.selectedBuild.version.toString();
|
||||
}
|
||||
}
|
||||
|
||||
enum DownloadStatus { form, downloading, extracting, error, done }
|
||||
260
lib/src/ui/dialog/dialog.dart
Normal file
260
lib/src/ui/dialog/dialog.dart
Normal file
@@ -0,0 +1,260 @@
|
||||
import 'package:bitsdojo_window/bitsdojo_window.dart';
|
||||
import 'package:clipboard/clipboard.dart';
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:reboot_launcher/src/ui/dialog/snackbar.dart';
|
||||
|
||||
import 'dialog_button.dart';
|
||||
|
||||
abstract class AbstractDialog extends StatelessWidget {
|
||||
const AbstractDialog({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context);
|
||||
}
|
||||
|
||||
class GenericDialog extends AbstractDialog {
|
||||
final Widget header;
|
||||
final List<DialogButton> buttons;
|
||||
final EdgeInsets? padding;
|
||||
|
||||
const GenericDialog({super.key, required this.header, required this.buttons, this.padding});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
MoveWindow(
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
|
||||
ContentDialog(
|
||||
style: ContentDialogThemeData(
|
||||
padding: padding ?? const EdgeInsets.only(left: 20, right: 20, top: 15.0, bottom: 5.0)
|
||||
),
|
||||
content: header,
|
||||
actions: buttons
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FormDialog extends AbstractDialog {
|
||||
final Widget content;
|
||||
final List<DialogButton> buttons;
|
||||
|
||||
const FormDialog({super.key, required this.content, required this.buttons});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Form(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
var parsed = buttons.map((entry) => _createFormButton(entry, context)).toList();
|
||||
return GenericDialog(
|
||||
header: content,
|
||||
buttons: parsed
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
DialogButton _createFormButton(DialogButton entry, BuildContext context) {
|
||||
if (entry.type != ButtonType.primary) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
return DialogButton(
|
||||
text: entry.text,
|
||||
type: entry.type,
|
||||
onTap: () {
|
||||
if(!Form.of(context)!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
entry.onTap?.call();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class InfoDialog extends AbstractDialog {
|
||||
final String text;
|
||||
final List<DialogButton>? buttons;
|
||||
|
||||
const InfoDialog({required this.text, this.buttons, super.key});
|
||||
|
||||
InfoDialog.ofOnly({required this.text, required DialogButton button, super.key})
|
||||
: buttons = [button];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GenericDialog(
|
||||
header: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Text(text, textAlign: TextAlign.center)
|
||||
),
|
||||
buttons: buttons ?? [_createDefaultButton()],
|
||||
padding: const EdgeInsets.only(left: 20, right: 20, top: 15.0, bottom: 15.0)
|
||||
);
|
||||
}
|
||||
|
||||
DialogButton _createDefaultButton() {
|
||||
return DialogButton(
|
||||
text: "Close",
|
||||
type: ButtonType.only
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ProgressDialog extends AbstractDialog {
|
||||
final String text;
|
||||
final Function()? onStop;
|
||||
|
||||
const ProgressDialog({required this.text, this.onStop, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GenericDialog(
|
||||
header: InfoLabel(
|
||||
label: text,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
width: double.infinity,
|
||||
child: const ProgressBar()
|
||||
),
|
||||
),
|
||||
buttons: [
|
||||
DialogButton(
|
||||
text: "Close",
|
||||
type: ButtonType.only,
|
||||
onTap: onStop
|
||||
)
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FutureBuilderDialog extends AbstractDialog {
|
||||
final Future future;
|
||||
final String loadingMessage;
|
||||
final Widget successfulBody;
|
||||
final Widget unsuccessfulBody;
|
||||
final Function(Object) errorMessageBuilder;
|
||||
final Function()? onError;
|
||||
final bool closeAutomatically;
|
||||
|
||||
const FutureBuilderDialog(
|
||||
{super.key,
|
||||
required this.future,
|
||||
required this.loadingMessage,
|
||||
required this.successfulBody,
|
||||
required this.unsuccessfulBody,
|
||||
required this.errorMessageBuilder,
|
||||
this.onError,
|
||||
this.closeAutomatically = false});
|
||||
|
||||
static Container ofMessage(String message) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
child: Text(
|
||||
message,
|
||||
textAlign: TextAlign.center
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder(
|
||||
future: future,
|
||||
builder: (context, snapshot) => GenericDialog(
|
||||
header: _createBody(context, snapshot),
|
||||
buttons: [_createButton(context, snapshot)]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Widget _createBody(BuildContext context, AsyncSnapshot snapshot){
|
||||
if (snapshot.hasError) {
|
||||
onError?.call();
|
||||
return ofMessage(errorMessageBuilder(snapshot.error!));
|
||||
}
|
||||
|
||||
if(snapshot.connectionState == ConnectionState.done && (snapshot.data == null || (snapshot.data is bool && !snapshot.data))){
|
||||
return unsuccessfulBody;
|
||||
}
|
||||
|
||||
if (!snapshot.hasData) {
|
||||
return _createLoadingBody();
|
||||
}
|
||||
|
||||
if(closeAutomatically){
|
||||
WidgetsBinding.instance
|
||||
.addPostFrameCallback((_) => Navigator.of(context).pop(true));
|
||||
return _createLoadingBody();
|
||||
}
|
||||
|
||||
return successfulBody;
|
||||
}
|
||||
|
||||
InfoLabel _createLoadingBody() {
|
||||
return InfoLabel(
|
||||
label: loadingMessage,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
width: double.infinity,
|
||||
child: const ProgressBar()),
|
||||
);
|
||||
}
|
||||
|
||||
DialogButton _createButton(BuildContext context, AsyncSnapshot snapshot){
|
||||
return DialogButton(
|
||||
text: snapshot.hasData
|
||||
|| snapshot.hasError
|
||||
|| (snapshot.connectionState == ConnectionState.done && snapshot.data == null) ? "Close" : "Stop",
|
||||
type: ButtonType.only,
|
||||
onTap: () => Navigator.of(context).pop(!snapshot.hasError && snapshot.hasData)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ErrorDialog extends AbstractDialog {
|
||||
final Object exception;
|
||||
final StackTrace? stackTrace;
|
||||
final Function(Object) errorMessageBuilder;
|
||||
|
||||
const ErrorDialog({super.key, required this.exception, required this.errorMessageBuilder, this.stackTrace});
|
||||
|
||||
static DialogButton createCopyErrorButton({required Object error, required StackTrace? stackTrace, required Function() onClick, ButtonType type = ButtonType.primary}) => DialogButton(
|
||||
text: "Copy error",
|
||||
type: type,
|
||||
onTap: () async {
|
||||
FlutterClipboard.controlC("An error occurred: $error\nStacktrace:\n $stackTrace");
|
||||
showMessage("Copied error to clipboard");
|
||||
onClick();
|
||||
},
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InfoDialog(
|
||||
text: errorMessageBuilder(exception),
|
||||
buttons: [
|
||||
DialogButton(
|
||||
type: stackTrace == null ? ButtonType.only : ButtonType.secondary
|
||||
),
|
||||
|
||||
if(stackTrace != null)
|
||||
createCopyErrorButton(
|
||||
error: exception,
|
||||
stackTrace: stackTrace,
|
||||
onClick: () => Navigator.pop(context)
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
64
lib/src/ui/dialog/dialog_button.dart
Normal file
64
lib/src/ui/dialog/dialog_button.dart
Normal file
@@ -0,0 +1,64 @@
|
||||
import 'package:fluent_ui/fluent_ui.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) {
|
||||
return widget.type == ButtonType.only ? _createOnlyButton() : _createButton();
|
||||
}
|
||||
|
||||
SizedBox _createOnlyButton() {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: _createButton()
|
||||
);
|
||||
}
|
||||
|
||||
Widget _createButton() {
|
||||
return widget.type == ButtonType.primary ? _createPrimaryActionButton()
|
||||
: _createSecondaryActionButton();
|
||||
}
|
||||
|
||||
Widget _createPrimaryActionButton() {
|
||||
return FilledButton(
|
||||
onPressed: widget.onTap!,
|
||||
child: Text(widget.text!),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _createSecondaryActionButton() {
|
||||
return Button(
|
||||
onPressed: widget.onTap ?? _onDefaultSecondaryActionTap,
|
||||
child: Text(widget.text ?? "Close"),
|
||||
);
|
||||
}
|
||||
|
||||
void _onDefaultSecondaryActionTap() {
|
||||
Navigator.of(context).pop(null);
|
||||
}
|
||||
}
|
||||
|
||||
enum ButtonType {
|
||||
primary,
|
||||
secondary,
|
||||
only
|
||||
}
|
||||
98
lib/src/ui/dialog/game_dialogs.dart
Normal file
98
lib/src/ui/dialog/game_dialogs.dart
Normal file
@@ -0,0 +1,98 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:reboot_launcher/src/model/fortnite_version.dart';
|
||||
|
||||
import '../../../main.dart';
|
||||
import 'dialog.dart';
|
||||
|
||||
const String _unsupportedServerError = "The build you are currently using is not supported by Reboot. "
|
||||
"This means that you cannot currently host this version of the game. "
|
||||
"For a list of supported versions, check #info in the Discord server. "
|
||||
"If you are unsure which version works best, use build 7.40. "
|
||||
"If you are a passionate programmer you can add support by opening a PR on Github. ";
|
||||
|
||||
const String _corruptedBuildError = "The build you are currently using is corrupted. "
|
||||
"This means that some critical files are missing for the game to launch. "
|
||||
"Download the build again from the launcher or, if it's not available there, from another source. "
|
||||
"Occasionally some files might get corrupted if there isn't enough space on your drive.";
|
||||
|
||||
Future<void> showBrokenError() async {
|
||||
showDialog(
|
||||
context: appKey.currentContext!,
|
||||
builder: (context) => const InfoDialog(
|
||||
text: "The backend server is not working correctly"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showMissingDllError(String name) async {
|
||||
showDialog(
|
||||
context: appKey.currentContext!,
|
||||
builder: (context) => InfoDialog(
|
||||
text: "$name dll is not a valid dll, fix it in the settings tab"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showTokenErrorFixable() async {
|
||||
showDialog(
|
||||
context: appKey.currentContext!,
|
||||
builder: (context) => const InfoDialog(
|
||||
text: "A token error occurred. "
|
||||
"The backend server has been automatically restarted to fix the issue. "
|
||||
"The game has been restarted automatically. "
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showTokenErrorCouldNotFix() async {
|
||||
showDialog(
|
||||
context: appKey.currentContext!,
|
||||
builder: (context) => const InfoDialog(
|
||||
text: "A token error occurred. "
|
||||
"The game couldn't be recovered, open an issue on Discord."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showTokenErrorUnfixable() async {
|
||||
showDialog(
|
||||
context: appKey.currentContext!,
|
||||
builder: (context) => const InfoDialog(
|
||||
text: "A token error occurred. "
|
||||
"This issue cannot be resolved automatically as the server isn't embedded."
|
||||
"Please restart the server manually, then relaunch your game to check if the issue has been fixed. "
|
||||
"Otherwise, open an issue on Discord."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showCorruptedBuildError(bool server, [Object? error, StackTrace? stackTrace]) async {
|
||||
if(error == null) {
|
||||
showDialog(
|
||||
context: appKey.currentContext!,
|
||||
builder: (context) => InfoDialog(
|
||||
text: server ? _unsupportedServerError : _corruptedBuildError
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: appKey.currentContext!,
|
||||
builder: (context) => ErrorDialog(
|
||||
exception: error,
|
||||
stackTrace: stackTrace,
|
||||
errorMessageBuilder: (exception) => _corruptedBuildError
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showMissingBuildError(FortniteVersion version) async {
|
||||
showDialog(
|
||||
context: appKey.currentContext!,
|
||||
builder: (context) => InfoDialog(
|
||||
text: "${version.location.path} no longer contains a Fortnite executable. "
|
||||
"This probably means that you deleted it or move it somewhere else."
|
||||
)
|
||||
);
|
||||
}
|
||||
318
lib/src/ui/dialog/server_dialogs.dart
Normal file
318
lib/src/ui/dialog/server_dialogs.dart
Normal file
@@ -0,0 +1,318 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
|
||||
import 'package:reboot_launcher/src/model/server_type.dart';
|
||||
import 'package:reboot_launcher/src/util/os.dart';
|
||||
import 'package:reboot_launcher/src/ui/dialog/snackbar.dart';
|
||||
import 'package:sync/semaphore.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../../main.dart';
|
||||
import '../../util/server.dart';
|
||||
import '../controller/server_controller.dart';
|
||||
import 'dialog.dart';
|
||||
import 'dialog_button.dart';
|
||||
|
||||
extension ServerControllerDialog on ServerController {
|
||||
static Semaphore semaphore = Semaphore();
|
||||
|
||||
Future<bool> restart(bool closeLocalPromptAutomatically) async {
|
||||
await resetWinNat();
|
||||
return (!started() || await stop()) && await toggle(closeLocalPromptAutomatically);
|
||||
}
|
||||
|
||||
Future<bool> toggle(bool closeLocalPromptAutomatically) async {
|
||||
try{
|
||||
semaphore.acquire();
|
||||
if (type() == ServerType.local) {
|
||||
return _pingSelfInteractive(closeLocalPromptAutomatically);
|
||||
}
|
||||
|
||||
var result = await _toggle();
|
||||
if(!result){
|
||||
started.value = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
var ping = await _pingSelfInteractive(true);
|
||||
if(!ping){
|
||||
started.value = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}finally{
|
||||
semaphore.release();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _toggle([ServerResultType? lastResultType]) async {
|
||||
if (started.value) {
|
||||
var result = await stop();
|
||||
if (!result) {
|
||||
started.value = true;
|
||||
_showCannotStopError();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
started.value = true;
|
||||
var conditions = await checkServerPreconditions(host.text, port.text, type.value);
|
||||
var result = conditions.type == ServerResultType.canStart ? await _startServer() : conditions;
|
||||
if(result.type == ServerResultType.alreadyStarted) {
|
||||
started.value = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
var handled = await _handleResultType(result, lastResultType);
|
||||
if (!handled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return handled;
|
||||
}
|
||||
|
||||
Future<ServerResult> _startServer() async {
|
||||
try{
|
||||
switch(type()){
|
||||
case ServerType.embedded:
|
||||
startServer(detached());
|
||||
break;
|
||||
case ServerType.remote:
|
||||
var uriResult = await _pingRemoteInteractive();
|
||||
if(uriResult == null){
|
||||
return ServerResult(
|
||||
type: ServerResultType.cannotPingServer
|
||||
);
|
||||
}
|
||||
|
||||
remoteServer = await startRemoteServer(uriResult);
|
||||
break;
|
||||
case ServerType.local:
|
||||
break;
|
||||
}
|
||||
}catch(error, stackTrace){
|
||||
return ServerResult(
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
type: ServerResultType.unknownError
|
||||
);
|
||||
}
|
||||
|
||||
return ServerResult(
|
||||
type: ServerResultType.canStart
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _handleResultType(ServerResult result, ServerResultType? lastResultType) async {
|
||||
var newResultType = result.type;
|
||||
switch (newResultType) {
|
||||
case ServerResultType.missingHostError:
|
||||
_showMissingHostError();
|
||||
return false;
|
||||
case ServerResultType.missingPortError:
|
||||
_showMissingPortError();
|
||||
return false;
|
||||
case ServerResultType.illegalPortError:
|
||||
_showIllegalPortError();
|
||||
return false;
|
||||
case ServerResultType.cannotPingServer:
|
||||
return false;
|
||||
case ServerResultType.backendPortTakenError:
|
||||
if (lastResultType == ServerResultType.backendPortTakenError) {
|
||||
_showPortTakenError(3551);
|
||||
return false;
|
||||
}
|
||||
|
||||
var result = await _showPortTakenDialog(3551);
|
||||
if (!result) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await freeLawinPort();
|
||||
await stop();
|
||||
return _toggle(newResultType);
|
||||
case ServerResultType.matchmakerPortTakenError:
|
||||
if (lastResultType == ServerResultType.matchmakerPortTakenError) {
|
||||
_showPortTakenError(8080);
|
||||
return false;
|
||||
}
|
||||
|
||||
var result = await _showPortTakenDialog(8080);
|
||||
if (!result) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await freeMatchmakerPort();
|
||||
await stop();
|
||||
return _toggle(newResultType);
|
||||
case ServerResultType.unknownError:
|
||||
if(lastResultType == ServerResultType.unknownError) {
|
||||
_showUnknownError(result);
|
||||
return false;
|
||||
}
|
||||
|
||||
await resetWinNat();
|
||||
await stop();
|
||||
return _toggle(newResultType);
|
||||
case ServerResultType.alreadyStarted:
|
||||
case ServerResultType.canStart:
|
||||
return true;
|
||||
case ServerResultType.stopped:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _pingSelfInteractive(bool closeAutomatically) async {
|
||||
try {
|
||||
Future<bool> ping() async {
|
||||
for(var i = 0; i < 3; i++){
|
||||
var result = await pingSelf(port.text);
|
||||
if(result != null){
|
||||
return true;
|
||||
}else {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
var future = _waitFutureOrTime(ping());
|
||||
var result = await showDialog<bool>(
|
||||
context: appKey.currentContext!,
|
||||
builder: (context) =>
|
||||
FutureBuilderDialog(
|
||||
future: future,
|
||||
loadingMessage: "Pinging ${type().id} server...",
|
||||
successfulBody: FutureBuilderDialog.ofMessage(
|
||||
"The ${type().id} server works correctly"),
|
||||
unsuccessfulBody: FutureBuilderDialog.ofMessage(
|
||||
"The ${type().id} server doesn't work. Check the backend tab for misconfigurations and try again."),
|
||||
errorMessageBuilder: (
|
||||
exception) => "An error occurred while pining the ${type().id} server: $exception",
|
||||
closeAutomatically: closeAutomatically
|
||||
)
|
||||
) ?? false;
|
||||
return result && await future;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Uri?> _pingRemoteInteractive() async {
|
||||
try {
|
||||
var mainFuture = ping(host.text, port.text).then((value) => value != null);
|
||||
var future = _waitFutureOrTime(mainFuture);
|
||||
var result = await showDialog<bool>(
|
||||
context: appKey.currentContext!,
|
||||
builder: (context) =>
|
||||
FutureBuilderDialog(
|
||||
future: future,
|
||||
closeAutomatically: false,
|
||||
loadingMessage: "Pinging remote server...",
|
||||
successfulBody: FutureBuilderDialog.ofMessage(
|
||||
"The server at ${host.text}:${port
|
||||
.text} works correctly"),
|
||||
unsuccessfulBody: FutureBuilderDialog.ofMessage(
|
||||
"The server at ${host.text}:${port
|
||||
.text} doesn't work. Check the hostname and/or the port and try again."),
|
||||
errorMessageBuilder: (exception) => "An error occurred while pining the server: $exception"
|
||||
)
|
||||
) ?? false;
|
||||
return result ? await future : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showPortTakenError(int port) async {
|
||||
showDialog(
|
||||
context: appKey.currentContext!,
|
||||
builder: (context) => InfoDialog(
|
||||
text: "Port $port is already in use and the associating process cannot be killed. Kill it manually and try again.",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _showPortTakenDialog(int port) async {
|
||||
return await showDialog<bool>(
|
||||
context: appKey.currentContext!,
|
||||
builder: (context) =>
|
||||
InfoDialog(
|
||||
text: "Port $port is already in use, do you want to kill the associated process?",
|
||||
buttons: [
|
||||
DialogButton(
|
||||
type: ButtonType.secondary,
|
||||
onTap: () => Navigator.of(context).pop(false),
|
||||
),
|
||||
DialogButton(
|
||||
text: "Kill",
|
||||
type: ButtonType.primary,
|
||||
onTap: () => Navigator.of(context).pop(true),
|
||||
),
|
||||
],
|
||||
)
|
||||
) ?? false;
|
||||
}
|
||||
|
||||
void _showCannotStopError() {
|
||||
if(!started.value){
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: appKey.currentContext!,
|
||||
builder: (context) =>
|
||||
const InfoDialog(
|
||||
text: "Cannot stop backend server"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
void showUnexpectedServerError() => showDialog(
|
||||
context: appKey.currentContext!,
|
||||
builder: (context) => InfoDialog(
|
||||
text: "The backend server died unexpectedly",
|
||||
buttons: [
|
||||
DialogButton(
|
||||
text: "Close",
|
||||
type: ButtonType.secondary,
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
),
|
||||
|
||||
DialogButton(
|
||||
text: "Open log",
|
||||
type: ButtonType.primary,
|
||||
onTap: () {
|
||||
if(serverLogFile.existsSync()){
|
||||
showMessage("No log is available");
|
||||
}else {
|
||||
launchUrl(serverLogFile.uri);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
),
|
||||
],
|
||||
)
|
||||
);
|
||||
|
||||
void _showIllegalPortError() => showMessage("Illegal port for backend server, use only numbers");
|
||||
|
||||
void _showMissingPortError() => showMessage("Missing port for backend server");
|
||||
|
||||
void _showMissingHostError() => showMessage("Missing the host name for backend server");
|
||||
|
||||
Future<Object?> _showUnknownError(ServerResult result) => showDialog(
|
||||
context: appKey.currentContext!,
|
||||
builder: (context) =>
|
||||
ErrorDialog(
|
||||
exception: result.error ?? Exception("Unknown error"),
|
||||
stackTrace: result.stackTrace,
|
||||
errorMessageBuilder: (exception) => "Cannot start the backend: an unknown error occurred"
|
||||
)
|
||||
);
|
||||
|
||||
Future<dynamic> _waitFutureOrTime(Future<bool> resultFuture) => Future.wait<bool>([resultFuture, Future.delayed(const Duration(seconds: 1)).then((value) => true)]).then((value) => value.reduce((f, s) => f && s));
|
||||
}
|
||||
13
lib/src/ui/dialog/snackbar.dart
Normal file
13
lib/src/ui/dialog/snackbar.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
|
||||
import '../../../main.dart';
|
||||
|
||||
void showMessage(String text){
|
||||
showSnackbar(
|
||||
appKey.currentContext!,
|
||||
Snackbar(
|
||||
content: Text(text, textAlign: TextAlign.center),
|
||||
extended: true
|
||||
)
|
||||
);
|
||||
}
|
||||
213
lib/src/ui/page/home_page.dart
Normal file
213
lib/src/ui/page/home_page.dart
Normal file
@@ -0,0 +1,213 @@
|
||||
import 'package:bitsdojo_window/bitsdojo_window.dart' hide WindowBorder;
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:get/get_rx/src/rx_types/rx_types.dart';
|
||||
import 'package:reboot_launcher/src/util/os.dart';
|
||||
import 'package:reboot_launcher/src/ui/page/launcher_page.dart';
|
||||
import 'package:reboot_launcher/src/ui/page/server_page.dart';
|
||||
import 'package:reboot_launcher/src/ui/page/settings_page.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../controller/settings_controller.dart';
|
||||
import '../widget/os/window_border.dart';
|
||||
import '../widget/os/window_buttons.dart';
|
||||
import 'hosting_page.dart';
|
||||
import 'info_page.dart';
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
const HomePage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> with WindowListener {
|
||||
static const double _defaultPadding = 12.0;
|
||||
|
||||
final SettingsController _settingsController = Get.find<SettingsController>();
|
||||
|
||||
final GlobalKey _searchKey = GlobalKey();
|
||||
final FocusNode _searchFocusNode = FocusNode();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final Rxn<List<NavigationPaneItem>> _searchItems = Rxn();
|
||||
final RxBool _focused = RxBool(true);
|
||||
final RxInt _index = RxInt(0);
|
||||
final RxBool _nestedNavigation = RxBool(false);
|
||||
final GlobalKey<NavigatorState> _settingsNavigatorKey = GlobalKey();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
windowManager.addListener(this);
|
||||
_searchController.addListener(_onSearch);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void _onSearch() {
|
||||
if (searchValue.isEmpty) {
|
||||
_searchItems.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
_searchItems.value = _allItems.whereType<PaneItem>()
|
||||
.where((item) => (item.title as Text).data!.toLowerCase().contains(searchValue.toLowerCase()))
|
||||
.toList()
|
||||
.cast<NavigationPaneItem>();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
windowManager.removeListener(this);
|
||||
_searchFocusNode.dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowFocus() {
|
||||
_focused.value = true;
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowBlur() {
|
||||
_focused.value = false;
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowResized() {
|
||||
_settingsController.saveWindowSize();
|
||||
super.onWindowResized();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowMoved() {
|
||||
_settingsController.saveWindowOffset(appWindow.position);
|
||||
super.onWindowMoved();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Obx(() => Stack(
|
||||
children: [
|
||||
NavigationView(
|
||||
paneBodyBuilder: (body) => Padding(
|
||||
padding: const EdgeInsets.all(_defaultPadding),
|
||||
child: body
|
||||
),
|
||||
appBar: NavigationAppBar(
|
||||
title: _draggableArea,
|
||||
actions: WindowTitleBar(focused: _focused()),
|
||||
leading: _backButton
|
||||
),
|
||||
pane: NavigationPane(
|
||||
selected: _selectedIndex,
|
||||
onChanged: _onIndexChanged,
|
||||
displayMode: PaneDisplayMode.auto,
|
||||
items: _items,
|
||||
footerItems: _footerItems,
|
||||
autoSuggestBox: _autoSuggestBox,
|
||||
autoSuggestBoxReplacement: const Icon(FluentIcons.search),
|
||||
),
|
||||
onOpenSearch: () => _searchFocusNode.requestFocus(),
|
||||
transitionBuilder: (child, animation) => child
|
||||
),
|
||||
if(_focused() && isWin11)
|
||||
const WindowBorder()
|
||||
]
|
||||
));
|
||||
|
||||
Widget get _backButton => Obx(() {
|
||||
// ignore: unused_local_variable
|
||||
var ignored = _nestedNavigation.value;
|
||||
return PaneItem(
|
||||
icon: const Icon(FluentIcons.back, size: 14.0),
|
||||
body: const SizedBox.shrink(),
|
||||
).build(
|
||||
context,
|
||||
false,
|
||||
_onBack(),
|
||||
displayMode: PaneDisplayMode.compact
|
||||
);
|
||||
});
|
||||
|
||||
void Function()? _onBack() {
|
||||
var navigator = _settingsNavigatorKey.currentState;
|
||||
if(navigator == null || !navigator.mounted || !navigator.canPop()){
|
||||
return null;
|
||||
}
|
||||
|
||||
return () async {
|
||||
Navigator.pop(navigator.context);
|
||||
_nestedNavigation.value = false;
|
||||
};
|
||||
}
|
||||
|
||||
void _onIndexChanged(int index) => _index.value = index;
|
||||
|
||||
TextBox get _autoSuggestBox => TextBox(
|
||||
key: _searchKey,
|
||||
controller: _searchController,
|
||||
placeholder: 'Search',
|
||||
focusNode: _searchFocusNode
|
||||
);
|
||||
|
||||
GestureDetector get _draggableArea => GestureDetector(
|
||||
onDoubleTap: () => appWindow.maximizeOrRestore(),
|
||||
onHorizontalDragStart: (event) => appWindow.startDragging(),
|
||||
onVerticalDragStart: (event) => appWindow.startDragging()
|
||||
);
|
||||
|
||||
int? get _selectedIndex {
|
||||
var searchItems = _searchItems();
|
||||
if (searchItems == null) {
|
||||
return _index();
|
||||
}
|
||||
|
||||
if(_index() >= _allItems.length){
|
||||
return null;
|
||||
}
|
||||
|
||||
var indexOnScreen = searchItems.indexOf(_allItems[_index()]);
|
||||
if (indexOnScreen.isNegative) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return indexOnScreen;
|
||||
}
|
||||
|
||||
List<NavigationPaneItem> get _allItems => [..._items, ..._footerItems];
|
||||
|
||||
List<NavigationPaneItem> get _footerItems => searchValue.isNotEmpty ? [] : [
|
||||
PaneItem(
|
||||
title: const Text("Settings"),
|
||||
icon: const Icon(FluentIcons.settings),
|
||||
body: SettingsPage()
|
||||
)
|
||||
];
|
||||
|
||||
List<NavigationPaneItem> get _items => _searchItems() ?? [
|
||||
PaneItem(
|
||||
title: const Text("Play"),
|
||||
icon: const Icon(FluentIcons.game),
|
||||
body: const LauncherPage()
|
||||
),
|
||||
|
||||
PaneItem(
|
||||
title: const Text("Host"),
|
||||
icon: const Icon(FluentIcons.server_processes),
|
||||
body: const HostingPage()
|
||||
),
|
||||
|
||||
PaneItem(
|
||||
title: const Text("Backend"),
|
||||
icon: const Icon(FluentIcons.user_window),
|
||||
body: ServerPage()
|
||||
),
|
||||
|
||||
PaneItem(
|
||||
title: const Text("Tutorial"),
|
||||
icon: const Icon(FluentIcons.info),
|
||||
body: InfoPage(_settingsNavigatorKey, _nestedNavigation)
|
||||
),
|
||||
];
|
||||
|
||||
String get searchValue => _searchController.text;
|
||||
}
|
||||
105
lib/src/ui/page/hosting_page.dart
Normal file
105
lib/src/ui/page/hosting_page.dart
Normal file
@@ -0,0 +1,105 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_launcher/src/ui/controller/hosting_controller.dart';
|
||||
import 'package:reboot_launcher/src/ui/widget/home/launch_button.dart';
|
||||
import 'package:reboot_launcher/src/ui/widget/home/version_selector.dart';
|
||||
import 'package:reboot_launcher/src/ui/widget/shared/setting_tile.dart';
|
||||
|
||||
|
||||
class HostingPage extends StatefulWidget {
|
||||
const HostingPage(
|
||||
{Key? key})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
State<HostingPage> createState() => _HostingPageState();
|
||||
}
|
||||
|
||||
class _HostingPageState extends State<HostingPage> {
|
||||
final HostingController _hostingController = Get.find<HostingController>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: double.infinity,
|
||||
child: InfoBar(
|
||||
title: Text("A window will pop up after the game server is started to modify its in-game settings"),
|
||||
severity: InfoBarSeverity.info
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16.0
|
||||
),
|
||||
SettingTile(
|
||||
title: "Game Server",
|
||||
subtitle: "Provide basic information about your server",
|
||||
expandedContentSpacing: 0,
|
||||
expandedContent: [
|
||||
SettingTile(
|
||||
title: "Name",
|
||||
subtitle: "The name of your game server",
|
||||
isChild: true,
|
||||
content: TextFormBox(
|
||||
placeholder: "Name",
|
||||
controller: _hostingController.name
|
||||
)
|
||||
),
|
||||
SettingTile(
|
||||
title: "Category",
|
||||
subtitle: "The category of your game server",
|
||||
isChild: true,
|
||||
content: TextFormBox(
|
||||
placeholder: "Category",
|
||||
controller: _hostingController.category
|
||||
)
|
||||
),
|
||||
SettingTile(
|
||||
title: "Discoverable",
|
||||
subtitle: "Make your server available to other players on the server browser",
|
||||
isChild: true,
|
||||
contentWidth: null,
|
||||
content: Obx(() => ToggleSwitch(
|
||||
checked: _hostingController.discoverable(),
|
||||
onChanged: (value) => _hostingController.discoverable.value = value
|
||||
))
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16.0,
|
||||
),
|
||||
SettingTile(
|
||||
title: "Version",
|
||||
subtitle: "Select the version of Fortnite you want to host",
|
||||
content: const VersionSelector(),
|
||||
expandedContent: [
|
||||
SettingTile(
|
||||
title: "Add a version from this PC's local storage",
|
||||
subtitle: "Versions coming from your local disk are not guaranteed to work",
|
||||
content: Button(
|
||||
onPressed: () => VersionSelector.openAddDialog(context),
|
||||
child: const Text("Add build"),
|
||||
),
|
||||
isChild: true
|
||||
),
|
||||
SettingTile(
|
||||
title: "Download any version from the cloud",
|
||||
subtitle: "A curated list of supported versions by Project Reboot",
|
||||
content: Button(
|
||||
onPressed: () => VersionSelector.openDownloadDialog(context),
|
||||
child: const Text("Download"),
|
||||
),
|
||||
isChild: true
|
||||
)
|
||||
]
|
||||
),
|
||||
const Expanded(child: SizedBox()),
|
||||
const LaunchButton(
|
||||
host: true
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
173
lib/src/ui/page/info_page.dart
Normal file
173
lib/src/ui/page/info_page.dart
Normal file
@@ -0,0 +1,173 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
|
||||
import '../controller/settings_controller.dart';
|
||||
import '../widget/shared/fluent_card.dart';
|
||||
|
||||
class InfoPage extends StatefulWidget {
|
||||
final GlobalKey<NavigatorState> navigatorKey;
|
||||
final RxBool nestedNavigation;
|
||||
const InfoPage(this.navigatorKey, this.nestedNavigation, {Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<InfoPage> createState() => _InfoPageState();
|
||||
}
|
||||
|
||||
class _InfoPageState extends State<InfoPage> {
|
||||
final List<String> _elseTitles = [
|
||||
"Open the home page",
|
||||
"Type the ip address of the host, including the port if it's not 7777\n The complete address should follow the schema ip:port",
|
||||
"Type your username if you haven't already",
|
||||
"Select the exact version that the host is using from the dropdown menu\n If necessary, install it using the download button",
|
||||
"As you want to play, select client from the dropdown menu",
|
||||
"Click launch to open the game\n If the game closes immediately, it means that the build you downloaded is corrupted\n The same is valid if an Unreal Crash window opens\n Download another and try again",
|
||||
"Once you are in game, click PLAY to enter in-game\n If this doesn't work open the Fortnite console by clicking the button above tab\n If nothing happens, make sure that your keyboard locale is set to English\n Type 'open TYPE_THE_IP' without the quotes, for example: open 85.182.12.1"
|
||||
];
|
||||
final List<String> _ownTitles = [
|
||||
"Open the home page",
|
||||
"Type 127.0.0.1 as the matchmaking host\n If you didn't know, 127.0.0.1 is the ip for your local machine",
|
||||
"Type your username if you haven't already",
|
||||
"Select the version you want to host\n If necessary, install it using the download button\n Check the supported versions in #info in the Discord server\n Fortnite 7.40 is the best one to use usually",
|
||||
"As you want to host, select headless server from the dropdown menu\n If the headless server doesn't work for your version, use the normal server instead\n The difference between the two is that the first doesn't render a fortnite instance\n Both will not allow you to play, only to host\n You will see an infinite loading screen when using the normal server\n If you want to also play continue reading",
|
||||
"Click launch to start the server and wait until the Reboot GUI shows up\n If the game closes immediately, it means that the build you downloaded is corrupted\n The same is valid if an Unreal Crash window opens\n Download another and try again",
|
||||
"To allow your friends to join your server, follow the instructions on playit.gg\n If you are an advanced user, open port 7777 on your router\n Finally, share your playit ip or public IPv4 address with your friends\n If you just want to play by yourself, skip this step",
|
||||
"When you want to start the game, click on the 'Start Bus Countdown' button\n Before clicking that button, make all of your friends join\n This is because joining mid-game isn't allowed",
|
||||
"If you also want to play, start a client by selecting Client from the dropdown menu\n Don't close or open again the launcher, use the same window\n Remember to keep both the headless server(or server) and client open\n If you want to close the client or server, simply switch between them using the menu\n The launcher will remember what instances you have opened",
|
||||
"Click launch to open the game\n If the game closes immediately, it means that the build you downloaded is corrupted\n The same is valid if an Unreal Crash window opens\n Download another and try again",
|
||||
"Once you are in game, click PLAY to enter in-game\n If this doesn't work open the Fortnite console by clicking the button above tab\n If nothing happens, make sure that your keyboard locale is set to English\n Type 'open TYPE_THE_IP' without the quotes, for example: open 85.182.12.1"
|
||||
];
|
||||
|
||||
final SettingsController _settingsController = Get.find<SettingsController>();
|
||||
late final ScrollController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_controller = ScrollController(initialScrollOffset: _settingsController.scrollingDistance);
|
||||
_controller.addListener(() {
|
||||
_settingsController.scrollingDistance = _controller.offset;
|
||||
});
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Navigator(
|
||||
key: widget.navigatorKey,
|
||||
initialRoute: "home",
|
||||
onGenerateRoute: (settings) {
|
||||
var screen = _createScreen(settings.name);
|
||||
return FluentPageRoute(
|
||||
builder: (context) => screen,
|
||||
settings: settings
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Widget _createScreen(String? name) {
|
||||
WidgetsBinding.instance
|
||||
.addPostFrameCallback((_) => widget.nestedNavigation.value = name != "home");
|
||||
switch(name){
|
||||
case "home":
|
||||
return _homeScreen;
|
||||
case "else":
|
||||
return _createInstructions(false);
|
||||
case "own":
|
||||
return _createInstructions(true);
|
||||
default:
|
||||
throw Exception("Unknown page: $name");
|
||||
}
|
||||
}
|
||||
|
||||
Widget get _homeScreen => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_createCardWidget(
|
||||
text: "Play on someone else's server",
|
||||
description: "If one of your friends is hosting a game server, click here",
|
||||
onClick: () => widget.navigatorKey.currentState?.pushNamed("else")
|
||||
),
|
||||
|
||||
const SizedBox(
|
||||
width: 8.0,
|
||||
),
|
||||
|
||||
_createCardWidget(
|
||||
text: "Host your own server",
|
||||
description: "If you want to create your own server to invite your friends or to play around by yourself, click here",
|
||||
onClick: () => widget.navigatorKey.currentState?.pushNamed("own")
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
SizedBox _createInstructions(bool own) {
|
||||
var titles = own ? _ownTitles : _elseTitles;
|
||||
var codeName = own ? "own" : "else";
|
||||
return SizedBox.expand(
|
||||
child: ListView.separated(
|
||||
controller: _controller,
|
||||
itemBuilder: (context, index) => Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: 20.0
|
||||
),
|
||||
child: FluentCard(
|
||||
child: ListTile(
|
||||
title: SelectableText("${index + 1}. ${titles[index]}"),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 12.0),
|
||||
child: Image.asset("assets/images/tutorial_${codeName}_${index + 1}.png"),
|
||||
)
|
||||
)
|
||||
),
|
||||
),
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 8.0),
|
||||
itemCount: titles.length,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Widget _createCardWidget({required String text, required String description, required Function() onClick}) => Expanded(
|
||||
child: SizedBox(
|
||||
height: double.infinity,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: onClick,
|
||||
child: FluentCard(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
text,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 20.0,
|
||||
fontWeight: FontWeight.bold
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
|
||||
Text(
|
||||
description,
|
||||
textAlign: TextAlign.center
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
216
lib/src/ui/page/launcher_page.dart
Normal file
216
lib/src/ui/page/launcher_page.dart
Normal file
@@ -0,0 +1,216 @@
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:flutter/material.dart' show Icons;
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_launcher/src/ui/controller/build_controller.dart';
|
||||
import 'package:reboot_launcher/src/ui/controller/game_controller.dart';
|
||||
import 'package:reboot_launcher/src/ui/controller/settings_controller.dart';
|
||||
import 'package:reboot_launcher/src/ui/widget/home/launch_button.dart';
|
||||
import 'package:reboot_launcher/src/ui/widget/home/version_selector.dart';
|
||||
import 'package:reboot_launcher/src/ui/widget/shared/setting_tile.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../model/update_status.dart';
|
||||
import '../../util/checks.dart';
|
||||
import '../../util/reboot.dart';
|
||||
import '../controller/update_controller.dart';
|
||||
|
||||
class LauncherPage extends StatefulWidget {
|
||||
const LauncherPage(
|
||||
{Key? key})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
State<LauncherPage> createState() => _LauncherPageState();
|
||||
}
|
||||
|
||||
class _LauncherPageState extends State<LauncherPage> {
|
||||
final GameController _gameController = Get.find<GameController>();
|
||||
final SettingsController _settingsController = Get.find<SettingsController>();
|
||||
final BuildController _buildController = Get.find<BuildController>();
|
||||
late final RxBool _showPasswordTrailing = RxBool(_gameController.password.text.isNotEmpty);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
if(_gameController.updateStatus() == UpdateStatus.waiting) {
|
||||
_startUpdater();
|
||||
_setupBuildWarning();
|
||||
}
|
||||
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void _setupBuildWarning() {
|
||||
void onCancelWarning() => WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if(!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
showSnackbar(context, const Snackbar(content: Text("Download cancelled")));
|
||||
_buildController.cancelledDownload(false);
|
||||
});
|
||||
_buildController.cancelledDownload.listen((value) => value ? onCancelWarning() : {});
|
||||
}
|
||||
|
||||
Future<void> _startUpdater() async {
|
||||
if(!_settingsController.autoUpdate()){
|
||||
_gameController.updateStatus.value = UpdateStatus.success;
|
||||
return;
|
||||
}
|
||||
|
||||
_gameController.updateStatus.value = UpdateStatus.started;
|
||||
try {
|
||||
updateTime = await downloadRebootDll(_settingsController.updateUrl.text, updateTime);
|
||||
_gameController.updateStatus.value = UpdateStatus.success;
|
||||
}catch(_) {
|
||||
_gameController.updateStatus.value = UpdateStatus.error;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Obx(() => !_settingsController.autoUpdate() || _gameController.updateStatus().isDone() ? _homePage : _updateScreen);
|
||||
|
||||
Widget get _homePage => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: _gameController.updateStatus() == UpdateStatus.error ? _updateError : const SizedBox(),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: SizedBox(height: _gameController.updateStatus() == UpdateStatus.error ? 16.0 : 0.0),
|
||||
),
|
||||
SettingTile(
|
||||
title: "Credentials",
|
||||
subtitle: "Your in-game login credentials",
|
||||
expandedContentSpacing: 0,
|
||||
expandedContent: [
|
||||
SettingTile(
|
||||
title: "Username",
|
||||
subtitle: "The username that other players will see when you are in game",
|
||||
isChild: true,
|
||||
content: TextFormBox(
|
||||
placeholder: "Username",
|
||||
controller: _gameController.username,
|
||||
autovalidateMode: AutovalidateMode.always
|
||||
),
|
||||
),
|
||||
SettingTile(
|
||||
title: "Password",
|
||||
subtitle: "The password of your account, only used if the backend requires it",
|
||||
isChild: true,
|
||||
content: Obx(() => TextFormBox(
|
||||
placeholder: "Password",
|
||||
controller: _gameController.password,
|
||||
autovalidateMode: AutovalidateMode.always,
|
||||
obscureText: !_gameController.showPassword.value,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
onChanged: (text) => _showPasswordTrailing.value = text.isNotEmpty,
|
||||
suffix: Button(
|
||||
onPressed: () => _gameController.showPassword.value = !_gameController.showPassword.value,
|
||||
style: ButtonStyle(
|
||||
shape: ButtonState.all(const CircleBorder()),
|
||||
backgroundColor: ButtonState.all(Colors.transparent)
|
||||
),
|
||||
child: Icon(
|
||||
_gameController.showPassword.value ? Icons.visibility_off : Icons.visibility,
|
||||
color: _showPasswordTrailing.value ? null : Colors.transparent
|
||||
),
|
||||
)
|
||||
))
|
||||
)
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16.0,
|
||||
),
|
||||
SettingTile(
|
||||
title: "Matchmaking host",
|
||||
subtitle: "Enter the IP address of the game server hosting the match",
|
||||
content: TextFormBox(
|
||||
placeholder: "IP:PORT",
|
||||
controller: _settingsController.matchmakingIp,
|
||||
validator: checkMatchmaking,
|
||||
autovalidateMode: AutovalidateMode.always
|
||||
),
|
||||
expandedContent: [
|
||||
SettingTile(
|
||||
title: "Browse available servers",
|
||||
subtitle: "Discover new game servers that fit your play-style",
|
||||
content: Button(
|
||||
onPressed: () => launchUrl(Uri.parse("https://google.com/search?q=One+Day+This+Will+Be+Ready")),
|
||||
child: const Text("Browse")
|
||||
),
|
||||
isChild: true
|
||||
)
|
||||
]
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16.0,
|
||||
),
|
||||
SettingTile(
|
||||
title: "Version",
|
||||
subtitle: "Select the version of Fortnite you want to play",
|
||||
content: const VersionSelector(),
|
||||
expandedContent: [
|
||||
SettingTile(
|
||||
title: "Add a version from this PC's local storage",
|
||||
subtitle: "Versions coming from your local disk are not guaranteed to work",
|
||||
content: Button(
|
||||
onPressed: () => VersionSelector.openAddDialog(context),
|
||||
child: const Text("Add build"),
|
||||
),
|
||||
isChild: true
|
||||
),
|
||||
SettingTile(
|
||||
title: "Download any version from the cloud",
|
||||
subtitle: "A curated list of supported versions by Project Reboot",
|
||||
content: Button(
|
||||
onPressed: () => VersionSelector.openDownloadDialog(context),
|
||||
child: const Text("Download"),
|
||||
),
|
||||
isChild: true
|
||||
)
|
||||
]
|
||||
),
|
||||
const Expanded(child: SizedBox()),
|
||||
const LaunchButton(
|
||||
host: false
|
||||
)
|
||||
],
|
||||
);
|
||||
|
||||
Widget get _updateScreen => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: const [
|
||||
ProgressRing(),
|
||||
SizedBox(height: 16.0),
|
||||
Text("Updating Reboot DLL...")
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget get _updateError => MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () => _startUpdater(),
|
||||
child: const SizedBox(
|
||||
width: double.infinity,
|
||||
child: InfoBar(
|
||||
title: Text("The reboot dll couldn't be downloaded: click here to try again"),
|
||||
severity: InfoBarSeverity.info
|
||||
)
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
92
lib/src/ui/page/server_page.dart
Normal file
92
lib/src/ui/page/server_page.dart
Normal file
@@ -0,0 +1,92 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_launcher/src/model/server_type.dart';
|
||||
import 'package:reboot_launcher/src/util/server.dart';
|
||||
import 'package:reboot_launcher/src/ui/controller/server_controller.dart';
|
||||
import 'package:reboot_launcher/src/ui/dialog/snackbar.dart';
|
||||
import 'package:reboot_launcher/src/ui/widget/server/server_type_selector.dart';
|
||||
import 'package:reboot_launcher/src/ui/widget/server/server_button.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../widget/shared/setting_tile.dart';
|
||||
|
||||
class ServerPage extends StatelessWidget {
|
||||
final ServerController _serverController = Get.find<ServerController>();
|
||||
|
||||
ServerPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: double.infinity,
|
||||
child: InfoBar(
|
||||
title: Text("The backend server handles authentication and parties, not game hosting"),
|
||||
severity: InfoBarSeverity.info
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16.0,
|
||||
),
|
||||
SettingTile(
|
||||
title: "Host",
|
||||
subtitle: "Enter the host of the backend server",
|
||||
content: TextFormBox(
|
||||
placeholder: "Host",
|
||||
controller: _serverController.host,
|
||||
enabled: _isRemote
|
||||
)
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16.0,
|
||||
),
|
||||
SettingTile(
|
||||
title: "Port",
|
||||
subtitle: "Enter the port of the backend server",
|
||||
content: TextFormBox(
|
||||
placeholder: "Port",
|
||||
controller: _serverController.port,
|
||||
enabled: _isRemote
|
||||
)
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16.0,
|
||||
),
|
||||
SettingTile(
|
||||
title: "Type",
|
||||
subtitle: "Select the type of backend to use",
|
||||
content: ServerTypeSelector()
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16.0,
|
||||
),
|
||||
SettingTile(
|
||||
title: "Detached",
|
||||
subtitle: "Choose whether the backend should be started as a separate process, useful for debugging",
|
||||
contentWidth: null,
|
||||
content: Obx(() => ToggleSwitch(
|
||||
checked: _serverController.detached(),
|
||||
onChanged: (value) => _serverController.detached.value = value
|
||||
))
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16.0,
|
||||
),
|
||||
SettingTile(
|
||||
title: "Server files",
|
||||
subtitle: "The location where the backend is stored",
|
||||
content: Button(
|
||||
onPressed: () => launchUrl(serverDirectory.uri),
|
||||
child: const Text("Open")
|
||||
)
|
||||
),
|
||||
const Expanded(child: SizedBox()),
|
||||
const ServerButton()
|
||||
]
|
||||
));
|
||||
}
|
||||
|
||||
bool get _isRemote => _serverController.type.value == ServerType.remote;
|
||||
}
|
||||
157
lib/src/ui/page/settings_page.dart
Normal file
157
lib/src/ui/page/settings_page.dart
Normal file
@@ -0,0 +1,157 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_launcher/src/ui/controller/game_controller.dart';
|
||||
import 'package:reboot_launcher/src/ui/controller/settings_controller.dart';
|
||||
import 'package:reboot_launcher/src/ui/dialog/dialog_button.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
|
||||
import '../../util/checks.dart';
|
||||
import '../../util/os.dart';
|
||||
import '../../util/selector.dart';
|
||||
import '../dialog/dialog.dart';
|
||||
import '../widget/shared/setting_tile.dart';
|
||||
|
||||
class SettingsPage extends StatelessWidget {
|
||||
final GameController _gameController = Get.find<GameController>();
|
||||
final SettingsController _settingsController = Get.find<SettingsController>();
|
||||
|
||||
SettingsPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SettingTile(
|
||||
title: "File settings",
|
||||
subtitle: "This section contains all the settings related to files used by Fortnite",
|
||||
expandedContent: [
|
||||
_createFileSetting(
|
||||
title: "Game server",
|
||||
description: "This file is injected to create a game server to host matches",
|
||||
controller: _settingsController.rebootDll
|
||||
),
|
||||
_createFileSetting(
|
||||
title: "Unreal engine console",
|
||||
description: "This file is injected to unlock the Unreal Engine Console in-game",
|
||||
controller: _settingsController.consoleDll
|
||||
),
|
||||
_createFileSetting(
|
||||
title: "Authentication patcher",
|
||||
description: "This file is injected to redirect all HTTP requests to the local backend",
|
||||
controller: _settingsController.authDll
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16.0,
|
||||
),
|
||||
SettingTile(
|
||||
title: "Automatic updates",
|
||||
subtitle: "Choose whether the launcher and its files should be automatically updated",
|
||||
contentWidth: null,
|
||||
content: Obx(() => ToggleSwitch(
|
||||
checked: _settingsController.autoUpdate(),
|
||||
onChanged: (value) => _settingsController.autoUpdate.value = value
|
||||
))
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16.0,
|
||||
),
|
||||
SettingTile(
|
||||
title: "Custom launch arguments",
|
||||
subtitle: "Enter additional arguments to use when launching the game",
|
||||
content: TextFormBox(
|
||||
placeholder: "Arguments...",
|
||||
controller: _gameController.customLaunchArgs,
|
||||
)
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16.0,
|
||||
),
|
||||
SettingTile(
|
||||
title: "Create a bug report",
|
||||
subtitle: "Help me fix bugs by reporting them",
|
||||
content: Button(
|
||||
onPressed: () => launchUrl(Uri.parse("https://github.com/Auties00/reboot_launcher/issues/new/choose")),
|
||||
child: const Text("Report a bug"),
|
||||
)
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16.0,
|
||||
),
|
||||
SettingTile(
|
||||
title: "Reset settings",
|
||||
subtitle: "Resets the launcher's settings to their default values",
|
||||
content: Button(
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (context) => InfoDialog(
|
||||
text: "Do you want to reset all settings to their default values? This action is irreversible",
|
||||
buttons: [
|
||||
DialogButton(
|
||||
type: ButtonType.secondary,
|
||||
text: "Close",
|
||||
),
|
||||
DialogButton(
|
||||
type: ButtonType.primary,
|
||||
text: "Reset",
|
||||
onTap: () {
|
||||
_settingsController.reset();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
),
|
||||
child: const Text("Reset"),
|
||||
)
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16.0,
|
||||
),
|
||||
SettingTile(
|
||||
title: "Version status",
|
||||
subtitle: "Current version: 7.0",
|
||||
content: Button(
|
||||
onPressed: () => launchUrl(installationDirectory.uri),
|
||||
child: const Text("Show Files"),
|
||||
)
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
Widget _createFileSetting({required String title, required String description, required TextEditingController controller}) => ListTile(
|
||||
title: Text(title),
|
||||
subtitle: Text(description),
|
||||
trailing: SizedBox(
|
||||
width: 256,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormBox(
|
||||
placeholder: "Path",
|
||||
controller: controller,
|
||||
validator: checkDll,
|
||||
autovalidateMode: AutovalidateMode.always
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 8.0,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 21.0),
|
||||
child: Button(
|
||||
onPressed: () async {
|
||||
var selected = await compute(openFilePicker, "dll");
|
||||
controller.text = selected ?? controller.text;
|
||||
},
|
||||
child: const Icon(FluentIcons.open_folder_horizontal),
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
44
lib/src/ui/widget/home/build_selector.dart
Normal file
44
lib/src/ui/widget/home/build_selector.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_launcher/src/ui/controller/build_controller.dart';
|
||||
import 'package:reboot_launcher/src/model/fortnite_build.dart';
|
||||
|
||||
class BuildSelector extends StatefulWidget {
|
||||
|
||||
const BuildSelector({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<BuildSelector> createState() => _BuildSelectorState();
|
||||
}
|
||||
|
||||
class _BuildSelectorState extends State<BuildSelector> {
|
||||
final BuildController _buildController = Get.find<BuildController>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InfoLabel(
|
||||
label: "Build",
|
||||
child: ComboBox<FortniteBuild>(
|
||||
placeholder: const Text('Select a fortnite build'),
|
||||
isExpanded: true,
|
||||
items: _createItems(),
|
||||
value: _buildController.selectedBuild,
|
||||
onChanged: (value) =>
|
||||
value == null ? {} : setState(() => _buildController.selectedBuild = value)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
List<ComboBoxItem<FortniteBuild>> _createItems() {
|
||||
return _buildController.builds!
|
||||
.map((element) => _createItem(element))
|
||||
.toList();
|
||||
}
|
||||
|
||||
ComboBoxItem<FortniteBuild> _createItem(FortniteBuild element) {
|
||||
return ComboBoxItem<FortniteBuild>(
|
||||
value: element,
|
||||
child: Text(element.version.toString())
|
||||
);
|
||||
}
|
||||
}
|
||||
407
lib/src/ui/widget/home/launch_button.dart
Normal file
407
lib/src/ui/widget/home/launch_button.dart
Normal file
@@ -0,0 +1,407 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:process_run/shell.dart';
|
||||
import 'package:reboot_launcher/src/ui/controller/game_controller.dart';
|
||||
import 'package:reboot_launcher/src/ui/controller/hosting_controller.dart';
|
||||
import 'package:reboot_launcher/src/ui/controller/server_controller.dart';
|
||||
import 'package:reboot_launcher/src/ui/dialog/dialog.dart';
|
||||
import 'package:reboot_launcher/src/ui/dialog/game_dialogs.dart';
|
||||
import 'package:reboot_launcher/src/ui/dialog/server_dialogs.dart';
|
||||
import 'package:reboot_launcher/src/model/fortnite_version.dart';
|
||||
import 'package:reboot_launcher/src/model/server_type.dart';
|
||||
import 'package:reboot_launcher/src/util/os.dart';
|
||||
import 'package:reboot_launcher/src/util/injector.dart';
|
||||
import 'package:reboot_launcher/src/util/patcher.dart';
|
||||
import 'package:reboot_launcher/src/util/server.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
import 'package:reboot_launcher/src/../main.dart';
|
||||
import 'package:reboot_launcher/src/ui/controller/settings_controller.dart';
|
||||
import 'package:reboot_launcher/src/ui/dialog/snackbar.dart';
|
||||
import 'package:reboot_launcher/src/model/game_instance.dart';
|
||||
|
||||
import '../../../util/process.dart';
|
||||
|
||||
class LaunchButton extends StatefulWidget {
|
||||
final bool host;
|
||||
|
||||
const LaunchButton({Key? key, required this.host}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<LaunchButton> createState() => _LaunchButtonState();
|
||||
}
|
||||
|
||||
class _LaunchButtonState extends State<LaunchButton> {
|
||||
final String _shutdownLine = "FOnlineSubsystemGoogleCommon::Shutdown()";
|
||||
final List<String> _corruptedBuildErrors = [
|
||||
"when 0 bytes remain",
|
||||
"Pak chunk signature verification failed!"
|
||||
];
|
||||
final List<String> _errorStrings = [
|
||||
"port 3551 failed: Connection refused",
|
||||
"Unable to login to Fortnite servers",
|
||||
"HTTP 400 response from ",
|
||||
"Network failure when attempting to check platform restrictions",
|
||||
"UOnlineAccountCommon::ForceLogout"
|
||||
];
|
||||
|
||||
final GameController _gameController = Get.find<GameController>();
|
||||
final HostingController _hostingController = Get.find<HostingController>();
|
||||
final ServerController _serverController = Get.find<ServerController>();
|
||||
final SettingsController _settingsController = Get.find<SettingsController>();
|
||||
final File _logFile = File("${assetsDirectory.path}\\logs\\game.log");
|
||||
bool _fail = false;
|
||||
Future? _executor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Align(
|
||||
alignment: AlignmentDirectional.bottomCenter,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Obx(() => SizedBox(
|
||||
height: 48,
|
||||
child: Button(
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
_hasStarted ? _stopMessage : _startMessage
|
||||
),
|
||||
),
|
||||
onPressed: () => _executor = _start()
|
||||
),
|
||||
)),
|
||||
),
|
||||
);
|
||||
|
||||
bool get _hasStarted => widget.host ? _hostingController.started() : _gameController.started();
|
||||
|
||||
void _setStarted(bool hosting, bool started) => hosting ? _hostingController.started.value = started : _gameController.started.value = started;
|
||||
|
||||
String get _startMessage => widget.host ? "Start hosting" : "Launch fortnite";
|
||||
|
||||
String get _stopMessage => widget.host ? "Stop hosting" : "Close fortnite";
|
||||
|
||||
Future<void> _start() async {
|
||||
if (_hasStarted) {
|
||||
_onStop(widget.host);
|
||||
return;
|
||||
}
|
||||
|
||||
_setStarted(widget.host, true);
|
||||
if (_gameController.username.text.isEmpty) {
|
||||
if(_serverController.type() != ServerType.local){
|
||||
showMessage("Missing username");
|
||||
_onStop(widget.host);
|
||||
return;
|
||||
}
|
||||
|
||||
showMessage("No username: expecting self sign in");
|
||||
}
|
||||
|
||||
if (_gameController.selectedVersion == null) {
|
||||
showMessage("No version is selected");
|
||||
_onStop(widget.host);
|
||||
return;
|
||||
}
|
||||
|
||||
for (var element in Injectable.values) {
|
||||
if(await _getDllPath(element, widget.host) == null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
_fail = false;
|
||||
var version = _gameController.selectedVersion!;
|
||||
var gamePath = version.executable?.path;
|
||||
if(gamePath == null){
|
||||
showMissingBuildError(version);
|
||||
_onStop(widget.host);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = _serverController.started() || await _serverController.toggle(true);
|
||||
if(!result){
|
||||
_onStop(widget.host);
|
||||
return;
|
||||
}
|
||||
|
||||
await compute(patchMatchmaking, version.executable!);
|
||||
await compute(patchHeadless, version.executable!);
|
||||
|
||||
var automaticallyStartedServer = await _startMatchMakingServer();
|
||||
await _startGameProcesses(version, widget.host, automaticallyStartedServer);
|
||||
|
||||
if(widget.host){
|
||||
await _showServerLaunchingWarning();
|
||||
}
|
||||
} catch (exception, stacktrace) {
|
||||
_closeDialogIfOpen(false);
|
||||
showCorruptedBuildError(widget.host, exception, stacktrace);
|
||||
_onStop(widget.host);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startGameProcesses(FortniteVersion version, bool host, bool hasChildServer) async {
|
||||
_setStarted(host, true);
|
||||
var launcherProcess = await _createLauncherProcess(version);
|
||||
var eacProcess = await _createEacProcess(version);
|
||||
var gameProcess = await _createGameProcess(version.executable!.path, host);
|
||||
var instance = GameInstance(gameProcess, launcherProcess, eacProcess, hasChildServer);
|
||||
if(host){
|
||||
_hostingController.instance = instance;
|
||||
}else{
|
||||
_gameController.instance = instance;
|
||||
}
|
||||
_injectOrShowError(Injectable.sslBypass, host);
|
||||
}
|
||||
|
||||
Future<bool> _startMatchMakingServer() async {
|
||||
if(widget.host){
|
||||
return false;
|
||||
}
|
||||
|
||||
var matchmakingIp = _settingsController.matchmakingIp.text;
|
||||
if(!matchmakingIp.contains("127.0.0.1") && !matchmakingIp.contains("localhost")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var version = _gameController.selectedVersion!;
|
||||
await _startGameProcesses(version, true, false);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<Process> _createGameProcess(String gamePath, bool host) async {
|
||||
var gameArgs = createRebootArgs(_safeUsername, host, _gameController.customLaunchArgs.text);
|
||||
var gameProcess = await Process.start(gamePath, gameArgs);
|
||||
gameProcess
|
||||
..exitCode.then((_) => _onEnd())
|
||||
..outLines.forEach((line) => _onGameOutput(line, host))
|
||||
..errLines.forEach((line) => _onGameOutput(line, host));
|
||||
return gameProcess;
|
||||
}
|
||||
|
||||
String get _safeUsername {
|
||||
if (_gameController.username.text.isEmpty) {
|
||||
return kDefaultPlayerName;
|
||||
}
|
||||
|
||||
var username = _gameController.username.text.replaceAll(RegExp("[^A-Za-z0-9]"), "").trim();
|
||||
if(username.isEmpty){
|
||||
return kDefaultPlayerName;
|
||||
}
|
||||
|
||||
return username;
|
||||
}
|
||||
|
||||
Future<Process?> _createLauncherProcess(FortniteVersion version) async {
|
||||
var launcherFile = version.launcher;
|
||||
if (launcherFile == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var launcherProcess = await Process.start(launcherFile.path, []);
|
||||
suspend(launcherProcess.pid);
|
||||
return launcherProcess;
|
||||
}
|
||||
|
||||
Future<Process?> _createEacProcess(FortniteVersion version) async {
|
||||
var eacFile = version.eacExecutable;
|
||||
if (eacFile == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var eacProcess = await Process.start(eacFile.path, []);
|
||||
suspend(eacProcess.pid);
|
||||
return eacProcess;
|
||||
}
|
||||
|
||||
void _onEnd() {
|
||||
if(_fail){
|
||||
return;
|
||||
}
|
||||
|
||||
_closeDialogIfOpen(false);
|
||||
_onStop(widget.host);
|
||||
}
|
||||
|
||||
void _closeDialogIfOpen(bool success) {
|
||||
var route = ModalRoute.of(appKey.currentContext!);
|
||||
if(route == null || route.isCurrent){
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.of(appKey.currentContext!).pop(success);
|
||||
}
|
||||
|
||||
Future<void> _showServerLaunchingWarning() async {
|
||||
var result = await showDialog<bool>(
|
||||
context: appKey.currentContext!,
|
||||
builder: (context) => ProgressDialog(
|
||||
text: "Launching headless server...",
|
||||
onStop: () =>_onEnd()
|
||||
)
|
||||
) ?? false;
|
||||
|
||||
if(result){
|
||||
return;
|
||||
}
|
||||
|
||||
_onStop(widget.host);
|
||||
}
|
||||
|
||||
void _onGameOutput(String line, bool host) {
|
||||
_logFile.createSync(recursive: true);
|
||||
_logFile.writeAsString("$line\n", mode: FileMode.append);
|
||||
if (line.contains(_shutdownLine)) {
|
||||
_onStop(host);
|
||||
return;
|
||||
}
|
||||
|
||||
if(_corruptedBuildErrors.any((element) => line.contains(element))){
|
||||
if(_fail){
|
||||
return;
|
||||
}
|
||||
|
||||
_fail = true;
|
||||
showCorruptedBuildError(host);
|
||||
_onStop(host);
|
||||
return;
|
||||
}
|
||||
|
||||
if(_errorStrings.any((element) => line.contains(element))){
|
||||
if(_fail){
|
||||
return;
|
||||
}
|
||||
|
||||
_fail = true;
|
||||
_closeDialogIfOpen(false);
|
||||
_showTokenError(host);
|
||||
return;
|
||||
}
|
||||
|
||||
if(line.contains("Region ")){
|
||||
if(!host){
|
||||
_injectOrShowError(Injectable.console, host);
|
||||
}else {
|
||||
_injectOrShowError(Injectable.reboot, host)
|
||||
.then((value) => _closeDialogIfOpen(true));
|
||||
}
|
||||
|
||||
_injectOrShowError(Injectable.memoryFix, host);
|
||||
var instance = host ? _hostingController.instance : _gameController.instance;
|
||||
instance?.tokenError = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showTokenError(bool host) async {
|
||||
var instance = host ? _hostingController.instance : _gameController.instance;
|
||||
if(_serverController.type() != ServerType.embedded) {
|
||||
showTokenErrorUnfixable();
|
||||
instance?.tokenError = true;
|
||||
return;
|
||||
}
|
||||
|
||||
var tokenError = instance?.tokenError;
|
||||
instance?.tokenError = true;
|
||||
await _serverController.restart(true);
|
||||
if (tokenError == true) {
|
||||
showTokenErrorCouldNotFix();
|
||||
return;
|
||||
}
|
||||
|
||||
showTokenErrorFixable();
|
||||
_onStop(host);
|
||||
_start();
|
||||
}
|
||||
|
||||
void _onStop(bool host) async {
|
||||
if(_executor != null){
|
||||
await _executor;
|
||||
}
|
||||
|
||||
var instance = host ? _hostingController.instance : _gameController.instance;
|
||||
if(instance != null){
|
||||
if(instance.hasChildServer){
|
||||
_onStop(true);
|
||||
}
|
||||
|
||||
instance.kill();
|
||||
if(host){
|
||||
_hostingController.instance = null;
|
||||
}else {
|
||||
_gameController.instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
_setStarted(host, false);
|
||||
}
|
||||
|
||||
Future<void> _injectOrShowError(Injectable injectable, bool hosting) async {
|
||||
var instance = hosting ? _hostingController.instance : _gameController.instance;
|
||||
if (instance == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var gameProcess = instance.gameProcess;
|
||||
var dllPath = await _getDllPath(injectable, hosting);
|
||||
if(dllPath == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await injectDll(gameProcess.pid, dllPath.path);
|
||||
} catch (exception) {
|
||||
showMessage("Cannot inject $injectable.dll: $exception");
|
||||
_onStop(hosting);
|
||||
}
|
||||
}
|
||||
|
||||
Future<File?> _getDllPath(Injectable injectable, bool hosting) async {
|
||||
Future<File> getPath(Injectable injectable) async {
|
||||
switch(injectable){
|
||||
case Injectable.reboot:
|
||||
return File(_settingsController.rebootDll.text);
|
||||
case Injectable.console:
|
||||
return File(_settingsController.consoleDll.text);
|
||||
case Injectable.sslBypass:
|
||||
return File(_settingsController.authDll.text);
|
||||
case Injectable.memoryFix:
|
||||
return File("${assetsDirectory.path}\\dlls\\memoryleak.dll");
|
||||
}
|
||||
}
|
||||
|
||||
var dllPath = await getPath(injectable);
|
||||
if(dllPath.existsSync()) {
|
||||
return dllPath;
|
||||
}
|
||||
|
||||
_onDllFail(dllPath, hosting);
|
||||
return null;
|
||||
}
|
||||
|
||||
void _onDllFail(File dllPath, bool hosting) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if(_fail){
|
||||
return;
|
||||
}
|
||||
|
||||
_fail = true;
|
||||
_closeDialogIfOpen(false);
|
||||
showMissingDllError(path.basename(dllPath.path));
|
||||
_onStop(hosting);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
enum Injectable {
|
||||
console,
|
||||
sslBypass,
|
||||
reboot,
|
||||
memoryFix
|
||||
}
|
||||
33
lib/src/ui/widget/home/version_name_input.dart
Normal file
33
lib/src/ui/widget/home/version_name_input.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_launcher/src/ui/controller/game_controller.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) {
|
||||
return TextFormBox(
|
||||
header: "Name",
|
||||
placeholder: "Type the version's name",
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
validator: _validate,
|
||||
);
|
||||
}
|
||||
|
||||
String? _validate(String? text) {
|
||||
if (text == null || text.isEmpty) {
|
||||
return 'Empty version name';
|
||||
}
|
||||
|
||||
if (_gameController.versions.value.any((element) => element.name == text)) {
|
||||
return 'This version already exists';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
271
lib/src/ui/widget/home/version_selector.dart
Normal file
271
lib/src/ui/widget/home/version_selector.dart
Normal file
@@ -0,0 +1,271 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_launcher/src/ui/controller/game_controller.dart';
|
||||
import 'package:reboot_launcher/src/ui/dialog/dialog.dart';
|
||||
import 'package:reboot_launcher/src/ui/dialog/dialog_button.dart';
|
||||
import 'package:reboot_launcher/src/model/fortnite_version.dart';
|
||||
import 'package:reboot_launcher/src/ui/dialog/add_local_version.dart';
|
||||
import 'package:reboot_launcher/src/ui/widget/shared/smart_check_box.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'package:reboot_launcher/src/ui/dialog/add_server_version.dart';
|
||||
import 'package:reboot_launcher/src/util/checks.dart';
|
||||
import '../shared/file_selector.dart';
|
||||
|
||||
class VersionSelector extends StatefulWidget {
|
||||
const VersionSelector({Key? key}) : super(key: key);
|
||||
|
||||
static void openDownloadDialog(BuildContext context) async {
|
||||
await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => const AddServerVersion()
|
||||
);
|
||||
}
|
||||
|
||||
static void openAddDialog(BuildContext context) async {
|
||||
await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AddLocalVersion());
|
||||
}
|
||||
|
||||
@override
|
||||
State<VersionSelector> createState() => _VersionSelectorState();
|
||||
}
|
||||
|
||||
class _VersionSelectorState extends State<VersionSelector> {
|
||||
final GameController _gameController = Get.find<GameController>();
|
||||
final CheckboxController _deleteFilesController = CheckboxController();
|
||||
final FlyoutController _flyoutController = FlyoutController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Obx(() => _createOptionsMenu(
|
||||
version: _gameController.selectedVersion,
|
||||
close: false,
|
||||
child: FlyoutTarget(
|
||||
controller: _flyoutController,
|
||||
child: DropDownButton(
|
||||
leading: Text(_gameController.selectedVersion?.name ?? "Select a version"),
|
||||
items: _createSelectorItems(context)
|
||||
),
|
||||
)
|
||||
));
|
||||
|
||||
List<MenuFlyoutItem> _createSelectorItems(BuildContext context) => _gameController.hasNoVersions ? [_createDefaultVersionItem()]
|
||||
: _gameController.versions.value
|
||||
.map((version) => _createVersionItem(context, version))
|
||||
.toList();
|
||||
|
||||
MenuFlyoutItem _createDefaultVersionItem() => MenuFlyoutItem(
|
||||
text: const Text("Please create or download a version"),
|
||||
onPressed: () {}
|
||||
);
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(version == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await _flyoutController.showFlyout<ContextualOption?>(
|
||||
builder: (context) => MenuFlyout(
|
||||
items: ContextualOption.values
|
||||
.map((entry) => _createOption(context, entry))
|
||||
.toList()
|
||||
)
|
||||
);
|
||||
_handleResult(result, version, close);
|
||||
},
|
||||
child: child
|
||||
);
|
||||
|
||||
void _handleResult(ContextualOption? result, FortniteVersion version, bool close) async {
|
||||
switch (result) {
|
||||
case ContextualOption.openExplorer:
|
||||
if(!mounted){
|
||||
return;
|
||||
}
|
||||
|
||||
if(close) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
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;
|
||||
if(!mounted || !result){
|
||||
return;
|
||||
}
|
||||
|
||||
if(close) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
_gameController.removeVersion(version);
|
||||
if (_gameController.selectedVersion?.name == version.name || _gameController.hasNoVersions) {
|
||||
_gameController.selectedVersion = null;
|
||||
}
|
||||
|
||||
if (_deleteFilesController.value && await version.location.exists()) {
|
||||
version.location.delete(recursive: true);
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
MenuFlyoutItem _createOption(BuildContext context, ContextualOption entry) {
|
||||
return MenuFlyoutItem(
|
||||
text: Text(entry.name),
|
||||
onPressed: () => Navigator.of(context).pop(entry)
|
||||
);
|
||||
}
|
||||
|
||||
bool _onExplorerError() {
|
||||
showSnackbar(
|
||||
context,
|
||||
const Snackbar(
|
||||
content: Text("This version doesn't exist on the local machine", textAlign: TextAlign.center),
|
||||
extended: true
|
||||
)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool?> _openDeleteDialog(BuildContext context, FortniteVersion version) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => ContentDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: double.infinity,
|
||||
child: Text("Are you sure you want to delete this version?")),
|
||||
|
||||
const SizedBox(height: 12.0),
|
||||
|
||||
SmartCheckBox(
|
||||
controller: _deleteFilesController,
|
||||
content: const Text("Delete version files from disk")
|
||||
)
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
Button(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Keep'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Delete'),
|
||||
)
|
||||
],
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _openRenameDialog(BuildContext context, FortniteVersion version) {
|
||||
var nameController = TextEditingController(text: version.name);
|
||||
var pathController = TextEditingController(text: version.location.path);
|
||||
return showDialog<String?>(
|
||||
context: context,
|
||||
builder: (context) => FormDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextFormBox(
|
||||
controller: nameController,
|
||||
header: "Name",
|
||||
placeholder: "Type the new version name",
|
||||
autofocus: true,
|
||||
validator: (text) => checkChangeVersion(text)
|
||||
),
|
||||
|
||||
const SizedBox(
|
||||
height: 16.0
|
||||
),
|
||||
|
||||
FileSelector(
|
||||
placeholder: "Type the new game folder",
|
||||
windowTitle: "Select game folder",
|
||||
controller: pathController,
|
||||
validator: checkGameFolder,
|
||||
folder: true
|
||||
),
|
||||
|
||||
const SizedBox(height: 8.0),
|
||||
],
|
||||
),
|
||||
buttons: [
|
||||
DialogButton(
|
||||
type: ButtonType.secondary
|
||||
),
|
||||
|
||||
DialogButton(
|
||||
text: "Save",
|
||||
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;
|
||||
|
||||
String get name {
|
||||
return this == ContextualOption.openExplorer ? "Open in explorer"
|
||||
: this == ContextualOption.modify ? "Modify"
|
||||
: "Delete";
|
||||
}
|
||||
}
|
||||
27
lib/src/ui/widget/os/window_border.dart
Normal file
27
lib/src/ui/widget/os/window_border.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
import 'package:bitsdojo_window/bitsdojo_window.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:reboot_launcher/src/util/os.dart';
|
||||
import 'package:system_theme/system_theme.dart';
|
||||
|
||||
class WindowBorder extends StatelessWidget {
|
||||
const WindowBorder({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: 1 / appWindow.scaleFactor
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: SystemTheme.accentColor.accent,
|
||||
width: appBarSize.toDouble()
|
||||
)
|
||||
)
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
51
lib/src/ui/widget/os/window_buttons.dart
Normal file
51
lib/src/ui/widget/os/window_buttons.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
import 'package:bitsdojo_window/bitsdojo_window.dart';
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:reboot_launcher/src/util/os.dart';
|
||||
import 'package:system_theme/system_theme.dart';
|
||||
|
||||
class WindowTitleBar extends StatelessWidget {
|
||||
final bool focused;
|
||||
|
||||
const WindowTitleBar({Key? key, required this.focused}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var lightMode = FluentTheme.of(context).brightness.isLight;
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
MinimizeWindowButton(
|
||||
colors: WindowButtonColors(
|
||||
iconNormal: focused || !isWin11 ? lightMode ? Colors.black : Colors.white : SystemTheme.accentColor.lighter,
|
||||
iconMouseDown: lightMode ? Colors.black : Colors.white,
|
||||
iconMouseOver: lightMode ? Colors.black : Colors.white,
|
||||
normal: Colors.transparent,
|
||||
mouseOver: _color,
|
||||
mouseDown: _color.withOpacity(0.7)),
|
||||
),
|
||||
MaximizeWindowButton(
|
||||
colors: WindowButtonColors(
|
||||
iconNormal: focused || !isWin11 ? lightMode ? Colors.black : Colors.white : SystemTheme.accentColor.lighter,
|
||||
iconMouseDown: lightMode ? Colors.black : Colors.white,
|
||||
iconMouseOver: lightMode ? Colors.black : Colors.white,
|
||||
normal: Colors.transparent,
|
||||
mouseOver: _color,
|
||||
mouseDown: _color.withOpacity(0.7)),
|
||||
),
|
||||
CloseWindowButton(
|
||||
colors: WindowButtonColors(
|
||||
iconNormal: focused || !isWin11 ? lightMode ? Colors.black : Colors.white : SystemTheme.accentColor.lighter,
|
||||
iconMouseDown: lightMode ? Colors.black : Colors.white,
|
||||
iconMouseOver: lightMode ? Colors.black : Colors.white,
|
||||
normal: Colors.transparent,
|
||||
mouseOver: Colors.red,
|
||||
mouseDown: Colors.red.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Color get _color =>
|
||||
SystemTheme.accentColor.accent;
|
||||
}
|
||||
46
lib/src/ui/widget/server/server_button.dart
Normal file
46
lib/src/ui/widget/server/server_button.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_launcher/src/ui/controller/server_controller.dart';
|
||||
import 'package:reboot_launcher/src/ui/dialog/server_dialogs.dart';
|
||||
import 'package:reboot_launcher/src/model/server_type.dart';
|
||||
|
||||
class ServerButton extends StatefulWidget {
|
||||
const ServerButton({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ServerButton> createState() => _ServerButtonState();
|
||||
}
|
||||
|
||||
class _ServerButtonState extends State<ServerButton> {
|
||||
final ServerController _serverController = Get.find<ServerController>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Align(
|
||||
alignment: AlignmentDirectional.bottomCenter,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Obx(() => SizedBox(
|
||||
height: 48,
|
||||
child: Button(
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: Text(_buttonText),
|
||||
),
|
||||
onPressed: () => _serverController.toggle(false)
|
||||
),
|
||||
)),
|
||||
),
|
||||
);
|
||||
|
||||
String get _buttonText {
|
||||
if(_serverController.type.value == ServerType.local){
|
||||
return "Check backend";
|
||||
}
|
||||
|
||||
if(_serverController.started.value){
|
||||
return "Stop backend";
|
||||
}
|
||||
|
||||
return "Start backend";
|
||||
}
|
||||
}
|
||||
34
lib/src/ui/widget/server/server_type_selector.dart
Normal file
34
lib/src/ui/widget/server/server_type_selector.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_launcher/src/ui/controller/server_controller.dart';
|
||||
import 'package:reboot_launcher/src/model/server_type.dart';
|
||||
|
||||
class ServerTypeSelector extends StatelessWidget {
|
||||
final ServerController _serverController = Get.find<ServerController>();
|
||||
|
||||
ServerTypeSelector({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DropDownButton(
|
||||
leading: Text(_serverController.type.value.name),
|
||||
items: ServerType.values
|
||||
.map((type) => _createItem(type))
|
||||
.toList()
|
||||
);
|
||||
}
|
||||
|
||||
MenuFlyoutItem _createItem(ServerType type) {
|
||||
return MenuFlyoutItem(
|
||||
text: Tooltip(
|
||||
message: type.message,
|
||||
child: Text(type.name)
|
||||
),
|
||||
onPressed: () async {
|
||||
await _serverController.stop();
|
||||
_serverController.type(type);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
93
lib/src/ui/widget/shared/file_selector.dart
Normal file
93
lib/src/ui/widget/shared/file_selector.dart
Normal file
@@ -0,0 +1,93 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_launcher/src/ui/dialog/snackbar.dart';
|
||||
|
||||
import 'package:reboot_launcher/src/util/selector.dart';
|
||||
|
||||
class FileSelector extends StatefulWidget {
|
||||
final String placeholder;
|
||||
final String windowTitle;
|
||||
final bool allowNavigator;
|
||||
final TextEditingController controller;
|
||||
final String? Function(String?) validator;
|
||||
final AutovalidateMode? validatorMode;
|
||||
final String? extension;
|
||||
final String? label;
|
||||
final bool folder;
|
||||
|
||||
const FileSelector(
|
||||
{required this.placeholder,
|
||||
required this.windowTitle,
|
||||
required this.controller,
|
||||
required this.validator,
|
||||
required this.folder,
|
||||
this.label,
|
||||
this.extension,
|
||||
this.validatorMode,
|
||||
this.allowNavigator = true,
|
||||
Key? key})
|
||||
: assert(folder || extension != null, "Missing extension for file selector"),
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
State<FileSelector> createState() => _FileSelectorState();
|
||||
}
|
||||
|
||||
class _FileSelectorState extends State<FileSelector> {
|
||||
bool _selecting = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.label != null ? InfoLabel(
|
||||
label: widget.label!,
|
||||
child: _buildBody,
|
||||
) : _buildBody;
|
||||
}
|
||||
|
||||
Widget get _buildBody => Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormBox(
|
||||
controller: widget.controller,
|
||||
placeholder: widget.placeholder,
|
||||
validator: widget.validator,
|
||||
autovalidateMode: widget.validatorMode ?? AutovalidateMode.onUserInteraction
|
||||
)
|
||||
),
|
||||
if (widget.allowNavigator)
|
||||
const SizedBox(width: 16.0),
|
||||
if (widget.allowNavigator)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 21.0),
|
||||
child: Button(
|
||||
onPressed: _onPressed,
|
||||
child: const Icon(FluentIcons.open_folder_horizontal)
|
||||
)
|
||||
)
|
||||
],
|
||||
);
|
||||
|
||||
void _onPressed() {
|
||||
if(_selecting){
|
||||
showMessage("Folder selector is already opened");
|
||||
return;
|
||||
}
|
||||
|
||||
_selecting = true;
|
||||
if(widget.folder) {
|
||||
compute(openFolderPicker, widget.windowTitle)
|
||||
.then((value) => widget.controller.text = value ?? widget.controller.text)
|
||||
.then((_) => _selecting = false);
|
||||
return;
|
||||
}
|
||||
|
||||
compute(openFilePicker, widget.extension!)
|
||||
.then((value) => widget.controller.text = value ?? widget.controller.text)
|
||||
.then((_) => _selecting = false);
|
||||
}
|
||||
}
|
||||
16
lib/src/ui/widget/shared/fluent_card.dart
Normal file
16
lib/src/ui/widget/shared/fluent_card.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
|
||||
class FluentCard extends StatelessWidget {
|
||||
final Widget child;
|
||||
const FluentCard({Key? key, required this.child}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Mica(
|
||||
elevation: 1,
|
||||
child: Card(
|
||||
backgroundColor: FluentTheme.of(context).menuColor,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(4.0)),
|
||||
child: child
|
||||
)
|
||||
);
|
||||
}
|
||||
90
lib/src/ui/widget/shared/setting_tile.dart
Normal file
90
lib/src/ui/widget/shared/setting_tile.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:reboot_launcher/src/ui/widget/shared/fluent_card.dart';
|
||||
|
||||
class SettingTile extends StatefulWidget {
|
||||
static const double kDefaultContentWidth = 200.0;
|
||||
static const double kDefaultSpacing = 8.0;
|
||||
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final Widget? content;
|
||||
final double? contentWidth;
|
||||
final List<Widget>? expandedContent;
|
||||
final double expandedContentSpacing;
|
||||
final bool isChild;
|
||||
|
||||
const SettingTile(
|
||||
{Key? key,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
this.content,
|
||||
this.contentWidth = kDefaultContentWidth,
|
||||
this.expandedContentSpacing = kDefaultSpacing,
|
||||
this.expandedContent,
|
||||
this.isChild = false})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
State<SettingTile> createState() => _SettingTileState();
|
||||
}
|
||||
|
||||
class _SettingTileState extends State<SettingTile> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if(widget.expandedContent == null){
|
||||
return _contentCard;
|
||||
}
|
||||
|
||||
return Mica(
|
||||
elevation: 1,
|
||||
child: Expander(
|
||||
initiallyExpanded: true,
|
||||
contentBackgroundColor: FluentTheme.of(context).menuColor,
|
||||
headerShape: (open) => const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(4.0)),
|
||||
),
|
||||
header: _header,
|
||||
headerHeight: 72,
|
||||
trailing: _trailing,
|
||||
content: _content
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget get _content {
|
||||
var contents = widget.expandedContent!;
|
||||
var items = List.generate(contents.length * 2, (index) => index % 2 == 0 ? contents[index ~/ 2] : SizedBox(height: widget.expandedContentSpacing));
|
||||
return Column(
|
||||
children: items
|
||||
);
|
||||
}
|
||||
|
||||
Widget get _trailing => SizedBox(
|
||||
width: widget.contentWidth,
|
||||
child: widget.content
|
||||
);
|
||||
|
||||
Widget get _header => ListTile(
|
||||
title: Text(widget.title),
|
||||
subtitle: Text(widget.subtitle)
|
||||
);
|
||||
|
||||
Widget get _contentCard {
|
||||
if (widget.isChild) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: _contentCardBody
|
||||
);
|
||||
}
|
||||
|
||||
return FluentCard(
|
||||
child: _contentCardBody,
|
||||
);
|
||||
}
|
||||
|
||||
Widget get _contentCardBody => ListTile(
|
||||
title: Text(widget.title),
|
||||
subtitle: Text(widget.subtitle),
|
||||
trailing: _trailing
|
||||
);
|
||||
}
|
||||
27
lib/src/ui/widget/shared/smart_check_box.dart
Normal file
27
lib/src/ui/widget/shared/smart_check_box.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
|
||||
class SmartCheckBox extends StatefulWidget {
|
||||
final CheckboxController controller;
|
||||
final Widget? content;
|
||||
const SmartCheckBox({Key? key, required this.controller, this.content}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<SmartCheckBox> createState() => _SmartCheckBoxState();
|
||||
}
|
||||
|
||||
class _SmartCheckBoxState extends State<SmartCheckBox> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Checkbox(
|
||||
checked: widget.controller.value,
|
||||
onChanged: (checked) => setState(() => widget.controller.value = checked ?? false),
|
||||
content: widget.content
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CheckboxController {
|
||||
bool value;
|
||||
|
||||
CheckboxController({this.value = false});
|
||||
}
|
||||
41
lib/src/ui/widget/shared/smart_input.dart
Normal file
41
lib/src/ui/widget/shared/smart_input.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
|
||||
class SmartInput extends StatelessWidget {
|
||||
final String? label;
|
||||
final String placeholder;
|
||||
final TextEditingController controller;
|
||||
final TextInputType type;
|
||||
final bool enabled;
|
||||
final VoidCallback? onTap;
|
||||
final bool readOnly;
|
||||
final AutovalidateMode validatorMode;
|
||||
final String? Function(String?)? validator;
|
||||
|
||||
const SmartInput(
|
||||
{Key? key,
|
||||
required this.placeholder,
|
||||
required this.controller,
|
||||
this.label,
|
||||
this.onTap,
|
||||
this.enabled = true,
|
||||
this.readOnly = false,
|
||||
this.type = TextInputType.text,
|
||||
this.validatorMode = AutovalidateMode.disabled,
|
||||
this.validator})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormBox(
|
||||
enabled: enabled,
|
||||
controller: controller,
|
||||
header: label,
|
||||
keyboardType: type,
|
||||
placeholder: placeholder,
|
||||
onTap: onTap,
|
||||
readOnly: readOnly,
|
||||
autovalidateMode: validatorMode,
|
||||
validator: validator
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user