mirror of
https://github.com/Auties00/Reboot-Launcher.git
synced 2026-01-13 03:02:22 +01:00
<feat: New project structure>
<feat: New release>
This commit is contained in:
24
gui/lib/src/controller/authenticator_controller.dart
Normal file
24
gui/lib/src/controller/authenticator_controller.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'package:reboot_common/common.dart';
|
||||
import 'package:reboot_launcher/src/controller/server_controller.dart';
|
||||
|
||||
class AuthenticatorController extends ServerController {
|
||||
AuthenticatorController() : super();
|
||||
|
||||
@override
|
||||
String get controllerName => "authenticator";
|
||||
|
||||
@override
|
||||
String get storageName => "reboot_authenticator";
|
||||
|
||||
@override
|
||||
String get defaultHost => kDefaultAuthenticatorHost;
|
||||
|
||||
@override
|
||||
String get defaultPort => kDefaultAuthenticatorPort;
|
||||
|
||||
@override
|
||||
Future<bool> get isPortFree => isAuthenticatorPortFree();
|
||||
|
||||
@override
|
||||
Future<bool> freePort() => freeAuthenticatorPort();
|
||||
}
|
||||
24
gui/lib/src/controller/build_controller.dart
Normal file
24
gui/lib/src/controller/build_controller.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_common/common.dart';
|
||||
|
||||
class BuildController extends GetxController {
|
||||
List<FortniteBuild>? _builds;
|
||||
Rxn<FortniteBuild> selectedBuild;
|
||||
|
||||
BuildController() : selectedBuild = Rxn();
|
||||
|
||||
List<FortniteBuild>? get builds => _builds;
|
||||
|
||||
set builds(List<FortniteBuild>? builds) {
|
||||
_builds = builds;
|
||||
if(builds == null || builds.isEmpty){
|
||||
return;
|
||||
}
|
||||
selectedBuild.value = builds[0];
|
||||
}
|
||||
|
||||
void reset(){
|
||||
_builds = null;
|
||||
selectedBuild.value = null;
|
||||
}
|
||||
}
|
||||
125
gui/lib/src/controller/game_controller.dart
Normal file
125
gui/lib/src/controller/game_controller.dart
Normal file
@@ -0,0 +1,125 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
import 'package:get/get.dart';
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
import 'package:reboot_common/common.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class GameController extends GetxController {
|
||||
late final String uuid;
|
||||
late final GetStorage _storage;
|
||||
late final TextEditingController username;
|
||||
late final TextEditingController password;
|
||||
late final TextEditingController customLaunchArgs;
|
||||
late final Rx<List<FortniteVersion>> versions;
|
||||
late final Rxn<FortniteVersion> _selectedVersion;
|
||||
late final RxBool started;
|
||||
late final RxBool autoStartGameServer;
|
||||
late final Rxn<Set<Map<String, dynamic>>> servers;
|
||||
late final Rxn<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);
|
||||
uuid = _storage.read("uuid") ?? const Uuid().v4();
|
||||
_storage.write("uuid", uuid);
|
||||
_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));
|
||||
customLaunchArgs = TextEditingController(text: _storage.read("custom_launch_args") ?? "");
|
||||
customLaunchArgs.addListener(() => _storage.write("custom_launch_args", customLaunchArgs.text));
|
||||
started = RxBool(false);
|
||||
autoStartGameServer = RxBool(_storage.read("auto_game_server") ?? true);
|
||||
autoStartGameServer.listen((value) => _storage.write("auto_game_server", value));
|
||||
var supabase = Supabase.instance.client;
|
||||
servers = Rxn();
|
||||
supabase.from('hosts')
|
||||
.stream(primaryKey: ['id'])
|
||||
.map((event) => event.where((element) => element["ip"] != null).toSet())
|
||||
.listen((event) {
|
||||
if(servers.value == null) {
|
||||
servers.value = event;
|
||||
}else {
|
||||
servers.value?.addAll(event);
|
||||
}
|
||||
});
|
||||
var serializedInstance = _storage.read("instance");
|
||||
instance = Rxn(serializedInstance != null ? GameInstance.fromJson(jsonDecode(serializedInstance)) : null);
|
||||
instance.listen((value) => _storage.write("instance", jsonEncode(value?.toJson())));
|
||||
}
|
||||
|
||||
void reset() {
|
||||
username.text = kDefaultPlayerName;
|
||||
password.text = "";
|
||||
customLaunchArgs.text = "";
|
||||
versions.value = [];
|
||||
autoStartGameServer.value = true;
|
||||
instance.value = null;
|
||||
}
|
||||
|
||||
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));
|
||||
if (selectedVersion?.name == version.name || hasNoVersions) {
|
||||
selectedVersion = null;
|
||||
}
|
||||
}
|
||||
|
||||
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.value = version;
|
||||
_storage.write("version", version?.name);
|
||||
}
|
||||
|
||||
void updateVersion(FortniteVersion version, Function(FortniteVersion) function) {
|
||||
versions.update((val) => function(version));
|
||||
}
|
||||
|
||||
Map<String, dynamic>? findServerById(String uuid) {
|
||||
try {
|
||||
print(uuid);
|
||||
return servers.value?.firstWhere((element) => element["id"] == uuid);
|
||||
} on StateError catch(_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
46
gui/lib/src/controller/hosting_controller.dart
Normal file
46
gui/lib/src/controller/hosting_controller.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
import 'package:get/get.dart';
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
import 'package:reboot_common/common.dart';
|
||||
|
||||
const String kDefaultServerName = "Reboot Game Server";
|
||||
const String kDefaultDescription = "Just another server";
|
||||
|
||||
class HostingController extends GetxController {
|
||||
late final GetStorage _storage;
|
||||
late final TextEditingController name;
|
||||
late final TextEditingController description;
|
||||
late final TextEditingController password;
|
||||
late final RxBool showPassword;
|
||||
late final RxBool discoverable;
|
||||
late final RxBool started;
|
||||
late final Rxn<GameInstance> instance;
|
||||
|
||||
HostingController() {
|
||||
_storage = GetStorage("reboot_hosting");
|
||||
name = TextEditingController(text: _storage.read("name") ?? kDefaultServerName);
|
||||
name.addListener(() => _storage.write("name", name.text));
|
||||
description = TextEditingController(text: _storage.read("description") ?? kDefaultDescription);
|
||||
description.addListener(() => _storage.write("description", description.text));
|
||||
password = TextEditingController(text: _storage.read("password") ?? "");
|
||||
password.addListener(() => _storage.write("password", password.text));
|
||||
discoverable = RxBool(_storage.read("discoverable") ?? true);
|
||||
discoverable.listen((value) => _storage.write("discoverable", value));
|
||||
started = RxBool(false);
|
||||
showPassword = RxBool(false);
|
||||
var serializedInstance = _storage.read("instance");
|
||||
instance = Rxn(serializedInstance != null ? GameInstance.fromJson(jsonDecode(serializedInstance)) : null);
|
||||
instance.listen((value) => _storage.write("instance", jsonEncode(value?.toJson())));
|
||||
}
|
||||
|
||||
void reset() {
|
||||
name.text = kDefaultServerName;
|
||||
description.text = kDefaultDescription;
|
||||
showPassword.value = false;
|
||||
discoverable.value = false;
|
||||
started.value = false;
|
||||
instance.value = null;
|
||||
}
|
||||
}
|
||||
30
gui/lib/src/controller/matchmaker_controller.dart
Normal file
30
gui/lib/src/controller/matchmaker_controller.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
import 'package:reboot_common/common.dart';
|
||||
import 'package:reboot_launcher/src/controller/server_controller.dart';
|
||||
|
||||
class MatchmakerController extends ServerController {
|
||||
late final TextEditingController gameServerAddress;
|
||||
|
||||
MatchmakerController() : super() {
|
||||
gameServerAddress = TextEditingController(text: storage.read("game_server_address") ?? kDefaultMatchmakerHost);
|
||||
gameServerAddress.addListener(() => storage.write("game_server_address", gameServerAddress.text));
|
||||
}
|
||||
|
||||
@override
|
||||
String get controllerName => "matchmaker";
|
||||
|
||||
@override
|
||||
String get storageName => "reboot_matchmaker";
|
||||
|
||||
@override
|
||||
String get defaultHost => kDefaultMatchmakerHost;
|
||||
|
||||
@override
|
||||
String get defaultPort => kDefaultMatchmakerPort;
|
||||
|
||||
@override
|
||||
Future<bool> get isPortFree => isMatchmakerPortFree();
|
||||
|
||||
@override
|
||||
Future<bool> freePort() => freeMatchmakerPort();
|
||||
}
|
||||
189
gui/lib/src/controller/server_controller.dart
Normal file
189
gui/lib/src/controller/server_controller.dart
Normal file
@@ -0,0 +1,189 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
import 'package:get/get.dart';
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
import 'package:reboot_common/common.dart';
|
||||
import 'package:sync/semaphore.dart';
|
||||
|
||||
abstract class ServerController extends GetxController {
|
||||
late final GetStorage storage;
|
||||
late final TextEditingController host;
|
||||
late final TextEditingController port;
|
||||
late final Rx<ServerType> type;
|
||||
late final Semaphore semaphore;
|
||||
late RxBool started;
|
||||
late RxBool detached;
|
||||
Process? embeddedServer;
|
||||
HttpServer? localServer;
|
||||
HttpServer? remoteServer;
|
||||
|
||||
ServerController() {
|
||||
storage = GetStorage(storageName);
|
||||
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.name}_host", host.text));
|
||||
port = TextEditingController(text: _readPort());
|
||||
port.addListener(() =>
|
||||
storage.write("${type.value.name}_port", port.text));
|
||||
detached = RxBool(storage.read("detached") ?? false);
|
||||
detached.listen((value) => storage.write("detached", value));
|
||||
semaphore = Semaphore();
|
||||
}
|
||||
|
||||
String get controllerName;
|
||||
|
||||
String get storageName;
|
||||
|
||||
String get defaultHost;
|
||||
|
||||
String get defaultPort;
|
||||
|
||||
Future<bool> get isPortFree;
|
||||
|
||||
Future<bool> get isPortTaken async => !(await isPortFree);
|
||||
|
||||
Future<bool> freePort();
|
||||
|
||||
void reset() async {
|
||||
type.value = ServerType.values.elementAt(0);
|
||||
for (var type in ServerType.values) {
|
||||
storage.write("${type.name}_host", null);
|
||||
storage.write("${type.name}_port", null);
|
||||
}
|
||||
|
||||
host.text = type.value != ServerType.remote ? defaultHost : "";
|
||||
port.text = defaultPort;
|
||||
detached.value = false;
|
||||
}
|
||||
|
||||
String _readHost() {
|
||||
String? value = storage.read("${type.value.name}_host");
|
||||
return value != null && value.isNotEmpty ? value
|
||||
: type.value != ServerType.remote ? defaultHost : "";
|
||||
}
|
||||
|
||||
String _readPort() =>
|
||||
storage.read("${type.value.name}_port") ?? defaultPort;
|
||||
|
||||
Stream<ServerResult> start() async* {
|
||||
try {
|
||||
var host = this.host.text.trim();
|
||||
if (host.isEmpty) {
|
||||
yield ServerResult(ServerResultType.missingHostError);
|
||||
return;
|
||||
}
|
||||
|
||||
var port = this.port.text.trim();
|
||||
if (port.isEmpty) {
|
||||
yield ServerResult(ServerResultType.missingPortError);
|
||||
return;
|
||||
}
|
||||
|
||||
var portNumber = int.tryParse(port);
|
||||
if (portNumber == null) {
|
||||
yield ServerResult(ServerResultType.illegalPortError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type() != ServerType.local && await isPortTaken) {
|
||||
yield ServerResult(ServerResultType.freeingPort);
|
||||
var result = await freePort();
|
||||
yield ServerResult(result ? ServerResultType.freePortSuccess : ServerResultType.freePortError);
|
||||
if(!result) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
switch(type()){
|
||||
case ServerType.embedded:
|
||||
embeddedServer = await startEmbeddedAuthenticator(detached());
|
||||
break;
|
||||
case ServerType.remote:
|
||||
yield ServerResult(ServerResultType.pingingRemote);
|
||||
var uriResult = await ping(host, port);
|
||||
if(uriResult == null) {
|
||||
yield ServerResult(ServerResultType.pingError);
|
||||
return;
|
||||
}
|
||||
|
||||
remoteServer = await startRemoteAuthenticatorProxy(uriResult);
|
||||
break;
|
||||
case ServerType.local:
|
||||
if(port != defaultPort) {
|
||||
localServer = await startRemoteAuthenticatorProxy(Uri.parse("http://$defaultHost:$defaultPort"));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
yield ServerResult(ServerResultType.pingingLocal);
|
||||
var uriResult = await pingSelf(defaultPort);
|
||||
if(uriResult == null) {
|
||||
yield ServerResult(ServerResultType.pingError);
|
||||
return;
|
||||
}
|
||||
|
||||
yield ServerResult(ServerResultType.startSuccess);
|
||||
started.value = true;
|
||||
}catch(error, stackTrace) {
|
||||
yield ServerResult(
|
||||
ServerResultType.startError,
|
||||
error: error,
|
||||
stackTrace: stackTrace
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> stop() async {
|
||||
started.value = false;
|
||||
try{
|
||||
switch(type()){
|
||||
case ServerType.embedded:
|
||||
freePort();
|
||||
break;
|
||||
case ServerType.remote:
|
||||
await remoteServer?.close(force: true);
|
||||
remoteServer = null;
|
||||
break;
|
||||
case ServerType.local:
|
||||
await localServer?.close(force: true);
|
||||
localServer = null;
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}catch(_){
|
||||
started.value = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Stream<ServerResult> restart() async* {
|
||||
await resetWinNat();
|
||||
if(started()) {
|
||||
await stop();
|
||||
}
|
||||
|
||||
yield* start();
|
||||
}
|
||||
|
||||
Stream<ServerResult> toggle() async* {
|
||||
if(started()) {
|
||||
await stop();
|
||||
}else {
|
||||
yield* start();
|
||||
}
|
||||
}
|
||||
}
|
||||
63
gui/lib/src/controller/settings_controller.dart
Normal file
63
gui/lib/src/controller/settings_controller.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
import 'package:get/get.dart';
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
import 'package:reboot_launcher/main.dart';
|
||||
import 'package:reboot_common/common.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
class SettingsController extends GetxController {
|
||||
static const String _kDefaultIp = "127.0.0.1";
|
||||
|
||||
late final GetStorage _storage;
|
||||
late final String originalDll;
|
||||
late final TextEditingController rebootDll;
|
||||
late final TextEditingController consoleDll;
|
||||
late final TextEditingController authDll;
|
||||
late final RxBool firstRun;
|
||||
late double width;
|
||||
late double height;
|
||||
late double? offsetX;
|
||||
late double? offsetY;
|
||||
late double scrollingDistance;
|
||||
|
||||
SettingsController() {
|
||||
_storage = GetStorage("reboot_settings");
|
||||
rebootDll = _createController("reboot", "reboot.dll");
|
||||
consoleDll = _createController("console", "console.dll");
|
||||
authDll = _createController("cobalt", "cobalt.dll");
|
||||
width = _storage.read("width") ?? kDefaultWindowWidth;
|
||||
height = _storage.read("height") ?? kDefaultWindowHeight;
|
||||
offsetX = _storage.read("offset_x");
|
||||
offsetY = _storage.read("offset_y");
|
||||
scrollingDistance = 0.0;
|
||||
firstRun = RxBool(_storage.read("first_run") ?? true);
|
||||
firstRun.listen((value) => _storage.write("first_run", value));
|
||||
}
|
||||
|
||||
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() async {
|
||||
var size = await windowManager.getSize();
|
||||
_storage.write("width", size.width);
|
||||
_storage.write("height", size.height);
|
||||
}
|
||||
|
||||
void saveWindowOffset(Offset position) {
|
||||
_storage.write("offset_x", position.dx);
|
||||
_storage.write("offset_y", position.dy);
|
||||
}
|
||||
|
||||
void reset(){
|
||||
rebootDll.text = _controllerDefaultPath("reboot.dll");
|
||||
consoleDll.text = _controllerDefaultPath("console.dll");
|
||||
authDll.text = _controllerDefaultPath("cobalt.dll");
|
||||
firstRun.value = true;
|
||||
writeMatchmakingIp(_kDefaultIp);
|
||||
}
|
||||
|
||||
String _controllerDefaultPath(String name) => "${assetsDirectory.path}\\dlls\\$name";
|
||||
}
|
||||
47
gui/lib/src/controller/update_controller.dart
Normal file
47
gui/lib/src/controller/update_controller.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
import 'package:get/get.dart';
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
import 'package:reboot_common/common.dart';
|
||||
|
||||
class UpdateController {
|
||||
late final GetStorage _storage;
|
||||
late final RxnInt timestamp;
|
||||
late final Rx<UpdateStatus> status;
|
||||
late final Rx<UpdateTimer> timer;
|
||||
late final TextEditingController url;
|
||||
|
||||
UpdateController() {
|
||||
_storage = GetStorage("reboot_update");
|
||||
timestamp = RxnInt(_storage.read("ts"));
|
||||
timestamp.listen((value) => _storage.write("ts", value));
|
||||
var timerIndex = _storage.read("timer");
|
||||
timer = Rx(timerIndex == null ? UpdateTimer.never : UpdateTimer.values.elementAt(timerIndex));
|
||||
timer.listen((value) => _storage.write("timer", value.index));
|
||||
url = TextEditingController(text: _storage.read("update_url") ?? rebootDownloadUrl);
|
||||
url.addListener(() => _storage.write("update_url", url.text));
|
||||
status = Rx(UpdateStatus.waiting);
|
||||
}
|
||||
|
||||
Future<void> update() async {
|
||||
if(timer.value == UpdateTimer.never) {
|
||||
status.value = UpdateStatus.success;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
timestamp.value = await downloadRebootDll(url.text, timestamp.value);
|
||||
status.value = UpdateStatus.success;
|
||||
}catch(_) {
|
||||
status.value = UpdateStatus.error;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void reset() {
|
||||
timestamp.value = null;
|
||||
timer.value = UpdateTimer.never;
|
||||
url.text = rebootDownloadUrl;
|
||||
status.value = UpdateStatus.waiting;
|
||||
update();
|
||||
}
|
||||
}
|
||||
256
gui/lib/src/dialog/dialog.dart
Normal file
256
gui/lib/src/dialog/dialog.dart
Normal file
@@ -0,0 +1,256 @@
|
||||
import 'package:clipboard/clipboard.dart';
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
import 'package:fluent_ui/fluent_ui.dart' as fluent show showDialog;
|
||||
import 'package:reboot_launcher/src/dialog/message.dart';
|
||||
import 'package:reboot_launcher/src/page/home_page.dart';
|
||||
|
||||
import 'dialog_button.dart';
|
||||
|
||||
Future<T?> showDialog<T extends Object?>({required WidgetBuilder builder}) => fluent.showDialog(
|
||||
context: pageKey.currentContext!,
|
||||
useRootNavigator: false,
|
||||
builder: builder
|
||||
);
|
||||
|
||||
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) => 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)
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
55
gui/lib/src/dialog/dialog_button.dart
Normal file
55
gui/lib/src/dialog/dialog_button.dart
Normal file
@@ -0,0 +1,55 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
|
||||
class DialogButton extends StatefulWidget {
|
||||
final String? text;
|
||||
final Function()? onTap;
|
||||
final ButtonType type;
|
||||
|
||||
const DialogButton(
|
||||
{Key? key,
|
||||
this.text,
|
||||
this.onTap,
|
||||
required this.type})
|
||||
: assert(type != ButtonType.primary || onTap != null,
|
||||
"OnTap handler cannot be null for primary buttons"),
|
||||
assert(type != ButtonType.primary || text != null,
|
||||
"Text cannot be null for primary buttons"),
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
State<DialogButton> createState() => _DialogButtonState();
|
||||
}
|
||||
|
||||
class _DialogButtonState extends State<DialogButton> {
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.type == ButtonType.only ? _onlyButton : _button;
|
||||
|
||||
SizedBox get _onlyButton => SizedBox(
|
||||
width: double.infinity,
|
||||
child: _button
|
||||
);
|
||||
|
||||
Widget get _button => widget.type == ButtonType.primary ? _primaryButton : _secondaryButton;
|
||||
|
||||
Widget get _primaryButton {
|
||||
return Button(
|
||||
onPressed: widget.onTap!,
|
||||
child: Text(widget.text!),
|
||||
);
|
||||
}
|
||||
|
||||
Widget get _secondaryButton {
|
||||
return Button(
|
||||
onPressed: widget.onTap ?? _onDefaultSecondaryActionTap,
|
||||
child: Text(widget.text ?? "Close"),
|
||||
);
|
||||
}
|
||||
|
||||
void _onDefaultSecondaryActionTap() => Navigator.of(context).pop(null);
|
||||
}
|
||||
|
||||
enum ButtonType {
|
||||
primary,
|
||||
secondary,
|
||||
only
|
||||
}
|
||||
38
gui/lib/src/dialog/message.dart
Normal file
38
gui/lib/src/dialog/message.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
|
||||
import 'package:reboot_launcher/src/page/home_page.dart';
|
||||
import 'package:sync/semaphore.dart';
|
||||
|
||||
Semaphore _semaphore = Semaphore();
|
||||
OverlayEntry? _lastOverlay;
|
||||
|
||||
void showMessage(String text, {InfoBarSeverity severity = InfoBarSeverity.info, bool loading = false, Duration? duration = snackbarShortDuration}) {
|
||||
try {
|
||||
_semaphore.acquire();
|
||||
if(_lastOverlay?.mounted == true) {
|
||||
_lastOverlay?.remove();
|
||||
}
|
||||
var pageIndexValue = pageIndex.value;
|
||||
_lastOverlay = showSnackbar(
|
||||
pageKey.currentContext!,
|
||||
InfoBar(
|
||||
title: Text(text),
|
||||
isLong: true,
|
||||
isIconVisible: true,
|
||||
content: SizedBox(
|
||||
width: double.infinity,
|
||||
child: loading ? const ProgressBar() : const SizedBox()
|
||||
),
|
||||
severity: severity
|
||||
),
|
||||
margin: EdgeInsets.only(
|
||||
left: 330.0,
|
||||
right: 16.0,
|
||||
bottom: pageIndexValue == 0 || pageIndexValue == 1 || pageIndexValue == 3 || pageIndexValue == 4 ? 72 : 16
|
||||
),
|
||||
duration: duration
|
||||
);
|
||||
}finally {
|
||||
_semaphore.release();
|
||||
}
|
||||
}
|
||||
36
gui/lib/src/interactive/error.dart
Normal file
36
gui/lib/src/interactive/error.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
|
||||
import 'package:reboot_launcher/src/page/home_page.dart';
|
||||
import 'package:reboot_launcher/src/dialog/dialog.dart';
|
||||
|
||||
|
||||
String? lastError;
|
||||
|
||||
void onError(Object? exception, StackTrace? stackTrace, bool framework) {
|
||||
if(exception == null){
|
||||
return;
|
||||
}
|
||||
|
||||
if(pageKey.currentContext == null || pageKey.currentState?.mounted == false){
|
||||
return;
|
||||
}
|
||||
|
||||
if(lastError == exception.toString()){
|
||||
return;
|
||||
}
|
||||
|
||||
lastError = exception.toString();
|
||||
var route = ModalRoute.of(pageKey.currentContext!);
|
||||
if(route != null && !route.isCurrent){
|
||||
Navigator.of(pageKey.currentContext!).pop(false);
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) => showDialog(
|
||||
builder: (context) =>
|
||||
ErrorDialog(
|
||||
exception: exception,
|
||||
stackTrace: stackTrace,
|
||||
errorMessageBuilder: (exception) => framework ? "An error was thrown by Flutter: $exception" : "An uncaught error was thrown: $exception"
|
||||
)
|
||||
));
|
||||
}
|
||||
86
gui/lib/src/interactive/game.dart
Normal file
86
gui/lib/src/interactive/game.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'package:reboot_common/common.dart';
|
||||
|
||||
import '../dialog/dialog.dart';
|
||||
|
||||
const String _unsupportedServerError = "The build you are currently using is not supported by Reboot. "
|
||||
"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 = "An unknown occurred while launching Fortnite. "
|
||||
"Some critical files could be missing in your installation. "
|
||||
"Download the build again from the launcher, not locally, or from a different source. "
|
||||
"Alternatively, something could have gone wrong in the launcher. ";
|
||||
|
||||
Future<void> showBrokenError() async {
|
||||
showDialog(
|
||||
builder: (context) => const InfoDialog(
|
||||
text: "The backend server is not working correctly"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showMissingDllError(String name) async {
|
||||
showDialog(
|
||||
builder: (context) => InfoDialog(
|
||||
text: "$name dll is not a valid dll, fix it in the settings tab"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showTokenErrorFixable() async {
|
||||
showDialog(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
builder: (context) => InfoDialog(
|
||||
text: server ? _unsupportedServerError : _corruptedBuildError
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
builder: (context) => ErrorDialog(
|
||||
exception: error,
|
||||
stackTrace: stackTrace,
|
||||
errorMessageBuilder: (exception) => "${_corruptedBuildError}Error message: $exception"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showMissingBuildError(FortniteVersion version) async {
|
||||
showDialog(
|
||||
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."
|
||||
)
|
||||
);
|
||||
}
|
||||
83
gui/lib/src/interactive/profile.dart
Normal file
83
gui/lib/src/interactive/profile.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
import 'package:flutter/material.dart' show Icons;
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_launcher/src/controller/game_controller.dart';
|
||||
import 'package:reboot_launcher/src/dialog/dialog.dart';
|
||||
import 'package:reboot_launcher/src/dialog/dialog_button.dart';
|
||||
|
||||
final GameController _gameController = Get.find<GameController>();
|
||||
|
||||
Future<bool> showProfileForm(BuildContext context) async{
|
||||
var showPassword = RxBool(false);
|
||||
var oldUsername = _gameController.username.text;
|
||||
var showPasswordTrailing = RxBool(oldUsername.isNotEmpty);
|
||||
var oldPassword = _gameController.password.text;
|
||||
var result = await showDialog<bool?>(
|
||||
builder: (context) => Obx(() => FormDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InfoLabel(
|
||||
label: "Username/Email",
|
||||
child: TextFormBox(
|
||||
placeholder: "Type your username or email",
|
||||
controller: _gameController.username,
|
||||
autovalidateMode: AutovalidateMode.always,
|
||||
enableSuggestions: true,
|
||||
autofocus: true,
|
||||
autocorrect: false,
|
||||
)
|
||||
),
|
||||
const SizedBox(height: 16.0),
|
||||
InfoLabel(
|
||||
label: "Password",
|
||||
child: TextFormBox(
|
||||
placeholder: "Type your password, if you have one",
|
||||
controller: _gameController.password,
|
||||
autovalidateMode: AutovalidateMode.always,
|
||||
obscureText: !showPassword.value,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
onChanged: (text) => showPasswordTrailing.value = text.isNotEmpty,
|
||||
suffix: Button(
|
||||
onPressed: () => showPassword.value = !showPassword.value,
|
||||
style: ButtonStyle(
|
||||
shape: ButtonState.all(const CircleBorder()),
|
||||
backgroundColor: ButtonState.all(Colors.transparent)
|
||||
),
|
||||
child: Icon(
|
||||
showPassword.value ? Icons.visibility_off : Icons.visibility,
|
||||
color: showPasswordTrailing.value ? null : Colors.transparent
|
||||
),
|
||||
)
|
||||
)
|
||||
),
|
||||
const SizedBox(height: 8.0)
|
||||
],
|
||||
),
|
||||
buttons: [
|
||||
DialogButton(
|
||||
text: "Cancel",
|
||||
type: ButtonType.secondary
|
||||
),
|
||||
|
||||
DialogButton(
|
||||
text: "Save",
|
||||
type: ButtonType.primary,
|
||||
onTap: () {
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
)
|
||||
]
|
||||
))
|
||||
) ?? false;
|
||||
if(result) {
|
||||
return true;
|
||||
}
|
||||
|
||||
_gameController.username.text = oldUsername;
|
||||
_gameController.password.text = oldPassword;
|
||||
return false;
|
||||
}
|
||||
222
gui/lib/src/interactive/server.dart
Normal file
222
gui/lib/src/interactive/server.dart
Normal file
@@ -0,0 +1,222 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clipboard/clipboard.dart';
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
import 'package:flutter/material.dart' show Icons;
|
||||
import 'package:get/get_rx/src/rx_types/rx_types.dart';
|
||||
import 'package:get/get_state_manager/src/rx_flutter/rx_obx_widget.dart';
|
||||
import 'package:reboot_launcher/src/controller/matchmaker_controller.dart';
|
||||
import 'package:reboot_launcher/src/controller/server_controller.dart';
|
||||
import 'package:reboot_launcher/src/dialog/dialog.dart';
|
||||
import 'package:reboot_launcher/src/dialog/dialog_button.dart';
|
||||
import 'package:reboot_launcher/src/dialog/message.dart';
|
||||
|
||||
import 'package:reboot_common/common.dart';
|
||||
import 'package:reboot_launcher/src/page/home_page.dart';
|
||||
import 'package:reboot_launcher/src/util/cryptography.dart';
|
||||
|
||||
extension ServerControllerDialog on ServerController {
|
||||
Future<bool> restartInteractive() async {
|
||||
var stream = restart();
|
||||
return await _handleStream(stream, false);
|
||||
}
|
||||
|
||||
Future<bool> toggleInteractive([bool showSuccessMessage = true]) async {
|
||||
var stream = toggle();
|
||||
return await _handleStream(stream, showSuccessMessage);
|
||||
}
|
||||
|
||||
|
||||
Future<bool> _handleStream(Stream<ServerResult> stream, bool showSuccessMessage) async {
|
||||
var completer = Completer<bool>();
|
||||
stream.listen((event) {
|
||||
switch (event.type) {
|
||||
case ServerResultType.missingHostError:
|
||||
showMessage(
|
||||
"Cannot launch game: missing hostname in $controllerName configuration",
|
||||
severity: InfoBarSeverity.error
|
||||
);
|
||||
break;
|
||||
case ServerResultType.missingPortError:
|
||||
showMessage(
|
||||
"Cannot launch game: missing port in $controllerName configuration",
|
||||
severity: InfoBarSeverity.error
|
||||
);
|
||||
break;
|
||||
case ServerResultType.illegalPortError:
|
||||
showMessage(
|
||||
"Cannot launch game: invalid port in $controllerName configuration",
|
||||
severity: InfoBarSeverity.error
|
||||
);
|
||||
break;
|
||||
case ServerResultType.freeingPort:
|
||||
case ServerResultType.freePortSuccess:
|
||||
case ServerResultType.freePortError:
|
||||
showMessage(
|
||||
"Message",
|
||||
loading: event.type == ServerResultType.freeingPort,
|
||||
severity: event.type == ServerResultType.freeingPort ? InfoBarSeverity.info : event.type == ServerResultType.freePortSuccess ? InfoBarSeverity.success : InfoBarSeverity.error
|
||||
);
|
||||
break;
|
||||
case ServerResultType.pingingRemote:
|
||||
showMessage(
|
||||
"Pinging remote server...",
|
||||
severity: InfoBarSeverity.info,
|
||||
loading: true,
|
||||
duration: const Duration(seconds: 10)
|
||||
);
|
||||
break;
|
||||
case ServerResultType.pingingLocal:
|
||||
showMessage(
|
||||
"Pinging ${type().name} server...",
|
||||
severity: InfoBarSeverity.info,
|
||||
loading: true,
|
||||
duration: const Duration(seconds: 10)
|
||||
);
|
||||
break;
|
||||
case ServerResultType.pingError:
|
||||
showMessage(
|
||||
"Cannot ping ${type().name} server",
|
||||
severity: InfoBarSeverity.error
|
||||
);
|
||||
break;
|
||||
case ServerResultType.startSuccess:
|
||||
if(showSuccessMessage) {
|
||||
showMessage(
|
||||
"The $controllerName was started successfully",
|
||||
severity: InfoBarSeverity.success
|
||||
);
|
||||
}
|
||||
completer.complete(true);
|
||||
break;
|
||||
case ServerResultType.startError:
|
||||
showMessage(
|
||||
"An error occurred while starting the $controllerName: ${event.error ?? "unknown error"}",
|
||||
severity: InfoBarSeverity.error
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if(event.type.isError) {
|
||||
completer.complete(false);
|
||||
}
|
||||
});
|
||||
|
||||
var result = await completer.future;
|
||||
if(result && type() == ServerType.embedded) {
|
||||
watchProcess(embeddedServer!.pid).then((value) {
|
||||
if(started()) {
|
||||
pageIndex.value = 3;
|
||||
started.value = false;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => showMessage(
|
||||
"The $controllerName was terminated unexpectedly: if this wasn't intentional, file a bug report",
|
||||
severity: InfoBarSeverity.warning,
|
||||
duration: snackbarLongDuration
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
extension MatchmakerControllerExtension on MatchmakerController {
|
||||
Future<void> joinServer(Map<String, dynamic> entry) async {
|
||||
var hashedPassword = entry["password"];
|
||||
var hasPassword = hashedPassword != null;
|
||||
var embedded = type.value == ServerType.embedded;
|
||||
var author = entry["author"];
|
||||
var encryptedIp = entry["ip"];
|
||||
if(!hasPassword) {
|
||||
_onSuccess(embedded, encryptedIp, author);
|
||||
return;
|
||||
}
|
||||
|
||||
var confirmPassword = await _askForPassword();
|
||||
if(confirmPassword == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(!checkPassword(confirmPassword, hashedPassword)) {
|
||||
showMessage(
|
||||
"Wrong password: please try again",
|
||||
duration: snackbarLongDuration,
|
||||
severity: InfoBarSeverity.error
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var decryptedIp = aes256Decrypt(encryptedIp, confirmPassword);
|
||||
_onSuccess(embedded, decryptedIp, author);
|
||||
}
|
||||
|
||||
|
||||
Future<String?> _askForPassword() async {
|
||||
var confirmPasswordController = TextEditingController();
|
||||
var showPassword = RxBool(false);
|
||||
var showPasswordTrailing = RxBool(false);
|
||||
return await showDialog<String?>(
|
||||
builder: (context) => FormDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InfoLabel(
|
||||
label: "Password",
|
||||
child: Obx(() => TextFormBox(
|
||||
placeholder: "Type the server's password",
|
||||
controller: confirmPasswordController,
|
||||
autovalidateMode: AutovalidateMode.always,
|
||||
obscureText: !showPassword.value,
|
||||
enableSuggestions: false,
|
||||
autofocus: true,
|
||||
autocorrect: false,
|
||||
onChanged: (text) => showPasswordTrailing.value = text.isNotEmpty,
|
||||
suffix: Button(
|
||||
onPressed: () => showPasswordTrailing.value = !showPasswordTrailing.value,
|
||||
style: ButtonStyle(
|
||||
shape: ButtonState.all(const CircleBorder()),
|
||||
backgroundColor: ButtonState.all(Colors.transparent)
|
||||
),
|
||||
child: Icon(
|
||||
showPassword.value ? Icons.visibility_off : Icons.visibility,
|
||||
color: showPassword.value ? null : Colors.transparent
|
||||
),
|
||||
)
|
||||
))
|
||||
),
|
||||
const SizedBox(height: 8.0)
|
||||
],
|
||||
),
|
||||
buttons: [
|
||||
DialogButton(
|
||||
text: "Cancel",
|
||||
type: ButtonType.secondary
|
||||
),
|
||||
|
||||
DialogButton(
|
||||
text: "Confirm",
|
||||
type: ButtonType.primary,
|
||||
onTap: () => Navigator.of(context).pop(confirmPasswordController.text)
|
||||
)
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
void _onSuccess(bool embedded, String decryptedIp, String author) {
|
||||
if(embedded) {
|
||||
gameServerAddress.text = decryptedIp;
|
||||
pageIndex.value = 0;
|
||||
}else {
|
||||
FlutterClipboard.controlC(decryptedIp);
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => showMessage(
|
||||
embedded ? "You joined $author's server successfully!" : "Copied IP to the clipboard",
|
||||
duration: snackbarLongDuration,
|
||||
severity: InfoBarSeverity.success
|
||||
));
|
||||
}
|
||||
}
|
||||
131
gui/lib/src/page/authenticator_page.dart
Normal file
131
gui/lib/src/page/authenticator_page.dart
Normal file
@@ -0,0 +1,131 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_common/common.dart';
|
||||
import 'package:reboot_launcher/src/controller/authenticator_controller.dart';
|
||||
import 'package:reboot_launcher/src/widget/server/start_button.dart';
|
||||
import 'package:reboot_launcher/src/widget/server/type_selector.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'package:reboot_launcher/src/widget/common/setting_tile.dart';
|
||||
|
||||
import 'package:reboot_launcher/src/dialog/dialog.dart';
|
||||
import 'package:reboot_launcher/src/dialog/dialog_button.dart';
|
||||
|
||||
class AuthenticatorPage extends StatefulWidget {
|
||||
const AuthenticatorPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AuthenticatorPage> createState() => _AuthenticatorPageState();
|
||||
}
|
||||
|
||||
class _AuthenticatorPageState extends State<AuthenticatorPage> with AutomaticKeepAliveClientMixin {
|
||||
final AuthenticatorController _authenticatorController = Get.find<AuthenticatorController>();
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return Obx(() => Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
children: [
|
||||
SettingTile(
|
||||
title: "Authenticator configuration",
|
||||
subtitle: "This section contains the authenticator's configuration",
|
||||
content: const ServerTypeSelector(
|
||||
authenticator: true
|
||||
),
|
||||
expandedContent: [
|
||||
if(_authenticatorController.type.value == ServerType.remote)
|
||||
SettingTile(
|
||||
title: "Host",
|
||||
subtitle: "The hostname of the authenticator",
|
||||
isChild: true,
|
||||
content: TextFormBox(
|
||||
placeholder: "Host",
|
||||
controller: _authenticatorController.host,
|
||||
readOnly: !_isRemote
|
||||
)
|
||||
),
|
||||
if(_authenticatorController.type.value != ServerType.embedded)
|
||||
SettingTile(
|
||||
title: "Port",
|
||||
subtitle: "The port of the authenticator",
|
||||
isChild: true,
|
||||
content: TextFormBox(
|
||||
placeholder: "Port",
|
||||
controller: _authenticatorController.port,
|
||||
readOnly: !_isRemote
|
||||
)
|
||||
),
|
||||
if(_authenticatorController.type.value == ServerType.embedded)
|
||||
SettingTile(
|
||||
title: "Detached",
|
||||
subtitle: "Whether the embedded authenticator should be started as a separate process, useful for debugging",
|
||||
contentWidth: null,
|
||||
isChild: true,
|
||||
content: Obx(() => ToggleSwitch(
|
||||
checked: _authenticatorController.detached(),
|
||||
onChanged: (value) => _authenticatorController.detached.value = value
|
||||
))
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
SettingTile(
|
||||
title: "Installation directory",
|
||||
subtitle: "Opens the folder where the embedded authenticator is located",
|
||||
content: Button(
|
||||
onPressed: () => launchUrl(authenticatorDirectory.uri),
|
||||
child: const Text("Show Files")
|
||||
)
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
SettingTile(
|
||||
title: "Reset authenticator",
|
||||
subtitle: "Resets the authenticator's settings to their default values",
|
||||
content: Button(
|
||||
onPressed: () => showDialog(
|
||||
builder: (context) => InfoDialog(
|
||||
text: "Do you want to reset all the setting in this tab to their default values? This action is irreversible",
|
||||
buttons: [
|
||||
DialogButton(
|
||||
type: ButtonType.secondary,
|
||||
text: "Close",
|
||||
),
|
||||
DialogButton(
|
||||
type: ButtonType.primary,
|
||||
text: "Reset",
|
||||
onTap: () {
|
||||
_authenticatorController.reset();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
),
|
||||
child: const Text("Reset"),
|
||||
)
|
||||
)
|
||||
]
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
const ServerButton(
|
||||
authenticator: true
|
||||
)
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
bool get _isRemote => _authenticatorController.type.value == ServerType.remote;
|
||||
}
|
||||
265
gui/lib/src/page/browse_page.dart
Normal file
265
gui/lib/src/page/browse_page.dart
Normal file
@@ -0,0 +1,265 @@
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_common/common.dart';
|
||||
|
||||
import 'package:reboot_launcher/src/controller/game_controller.dart';
|
||||
import 'package:reboot_launcher/src/controller/matchmaker_controller.dart';
|
||||
import 'package:reboot_launcher/src/interactive/server.dart';
|
||||
import 'package:reboot_launcher/src/widget/common/setting_tile.dart';
|
||||
import 'package:skeletons/skeletons.dart';
|
||||
|
||||
import 'package:reboot_launcher/src/controller/hosting_controller.dart';
|
||||
|
||||
class BrowsePage extends StatefulWidget {
|
||||
const BrowsePage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<BrowsePage> createState() => _BrowsePageState();
|
||||
}
|
||||
|
||||
class _BrowsePageState extends State<BrowsePage> with AutomaticKeepAliveClientMixin {
|
||||
final GameController _gameController = Get.find<GameController>();
|
||||
final MatchmakerController _matchmakerController = Get.find<MatchmakerController>();
|
||||
final TextEditingController _filterController = TextEditingController();
|
||||
final StreamController<String> _filterControllerStream = StreamController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return FutureBuilder(
|
||||
future: Future.delayed(const Duration(seconds: 1)), // Fake delay to show loading
|
||||
builder: (context, futureSnapshot) => Obx(() {
|
||||
var ready = futureSnapshot.connectionState == ConnectionState.done;
|
||||
var data = _gameController.servers.value;
|
||||
if(ready && data?.isEmpty == true) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"No servers are available right now",
|
||||
style: FluentTheme.of(context).typography.titleLarge,
|
||||
),
|
||||
Text(
|
||||
"Host a server yourself or come back later",
|
||||
style: FluentTheme.of(context).typography.body
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_buildSearchBar(ready),
|
||||
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: StreamBuilder<String?>(
|
||||
stream: _filterControllerStream.stream,
|
||||
builder: (context, filterSnapshot) {
|
||||
var items = _getItems(data, filterSnapshot.data, ready);
|
||||
var itemsCount = items != null ? items.length * 2 : null;
|
||||
if(itemsCount == 0) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"No results found",
|
||||
style: FluentTheme.of(context).typography.titleLarge,
|
||||
),
|
||||
Text(
|
||||
"No server matches your query",
|
||||
style: FluentTheme.of(context).typography.body
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: itemsCount,
|
||||
itemBuilder: (context, index) {
|
||||
if(index % 2 != 0) {
|
||||
return const SizedBox(
|
||||
height: 8.0
|
||||
);
|
||||
}
|
||||
|
||||
var entry = _getItem(index ~/ 2, items);
|
||||
if(!ready || entry == null) {
|
||||
return const SettingTile(
|
||||
content: SkeletonAvatar(
|
||||
style: SkeletonAvatarStyle(
|
||||
height: 32,
|
||||
width: 64
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
var hasPassword = entry["password"] != null;
|
||||
return SettingTile(
|
||||
title: "${_formatName(entry)} • ${entry["author"]}",
|
||||
subtitle: "${_formatDescription(entry)} • ${_formatVersion(entry)}",
|
||||
content: Button(
|
||||
onPressed: () => _matchmakerController.joinServer(entry),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if(hasPassword)
|
||||
const Icon(FluentIcons.lock),
|
||||
if(hasPassword)
|
||||
const SizedBox(width: 8.0),
|
||||
Text(_matchmakerController.type.value == ServerType.embedded ? "Join Server" : "Copy IP"),
|
||||
],
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Set<Map<String, dynamic>>? _getItems(Set<Map<String, dynamic>>? data, String? filter, bool ready) {
|
||||
if (!ready) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (data == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.where((entry) => _isValidItem(entry, filter)).toSet();
|
||||
}
|
||||
|
||||
bool _isValidItem(Map<String, dynamic> entry, String? filter) =>
|
||||
(entry["discoverable"] ?? false) && (filter == null || _filterServer(entry, filter));
|
||||
|
||||
bool _filterServer(Map<String, dynamic> element, String filter) {
|
||||
String? id = element["id"];
|
||||
if(id?.toLowerCase().contains(filter) == true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
var uri = Uri.tryParse(filter);
|
||||
if(uri != null && id?.toLowerCase().contains(uri.host.toLowerCase()) == true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String? name = element["name"];
|
||||
if(name?.toLowerCase().contains(filter) == true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String? author = element["author"];
|
||||
if(author?.toLowerCase().contains(filter) == true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String? description = element["description"];
|
||||
if(description?.toLowerCase().contains(filter) == true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Widget _buildSearchBar(bool ready) {
|
||||
if(ready) {
|
||||
return TextBox(
|
||||
placeholder: 'Find a server',
|
||||
controller: _filterController,
|
||||
onChanged: (value) => _filterControllerStream.add(value),
|
||||
suffix: _searchBarIcon,
|
||||
);
|
||||
}
|
||||
|
||||
return const SkeletonLine(
|
||||
style: SkeletonLineStyle(
|
||||
height: 32
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Widget get _searchBarIcon => Button(
|
||||
onPressed: _filterController.text.isEmpty ? null : () {
|
||||
_filterController.clear();
|
||||
_filterControllerStream.add("");
|
||||
},
|
||||
style: ButtonStyle(
|
||||
backgroundColor: _filterController.text.isNotEmpty ? null : ButtonState.all(Colors.transparent),
|
||||
border: _filterController.text.isNotEmpty ? null : ButtonState.all(const BorderSide(color: Colors.transparent))
|
||||
),
|
||||
child: _searchBarIconData
|
||||
);
|
||||
|
||||
Widget get _searchBarIconData {
|
||||
var color = FluentTheme.of(context).resources.textFillColorPrimary;
|
||||
if (_filterController.text.isNotEmpty) {
|
||||
return Icon(
|
||||
FluentIcons.clear,
|
||||
size: 8.0,
|
||||
color: color
|
||||
);
|
||||
}
|
||||
|
||||
return Transform.flip(
|
||||
flipX: true,
|
||||
child: Icon(
|
||||
FluentIcons.search,
|
||||
size: 12.0,
|
||||
color: color
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _getItem(int index, Set? data) {
|
||||
if(data == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (index >= data.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.elementAt(index);
|
||||
}
|
||||
|
||||
String _formatName(Map<String, dynamic> entry) {
|
||||
String result = entry['name'];
|
||||
return result.isEmpty ? kDefaultServerName : result;
|
||||
}
|
||||
|
||||
String _formatDescription(Map<String, dynamic> entry) {
|
||||
String result = entry['description'];
|
||||
return result.isEmpty ? kDefaultDescription : result;
|
||||
}
|
||||
|
||||
String _formatVersion(Map<String, dynamic> entry) {
|
||||
var version = entry['version'];
|
||||
var versionSplit = version.indexOf("-");
|
||||
var minimalVersion = version = versionSplit != -1 ? version.substring(0, versionSplit) : version;
|
||||
String result = minimalVersion.endsWith(".0") ? minimalVersion.substring(0, minimalVersion.length - 2) : minimalVersion;
|
||||
if(result.toLowerCase().startsWith("fortnite ")) {
|
||||
result = result.substring(0, 10);
|
||||
}
|
||||
|
||||
return "Fortnite $result";
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
||||
253
gui/lib/src/page/home_page.dart
Normal file
253
gui/lib/src/page/home_page.dart
Normal file
@@ -0,0 +1,253 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_launcher/src/page/browse_page.dart';
|
||||
import 'package:reboot_launcher/src/page/authenticator_page.dart';
|
||||
import 'package:reboot_launcher/src/page/matchmaker_page.dart';
|
||||
import 'package:reboot_launcher/src/page/play_page.dart';
|
||||
import 'package:reboot_launcher/src/page/settings_page.dart';
|
||||
import 'package:reboot_launcher/src/util/os.dart';
|
||||
import 'package:reboot_launcher/src/widget/home/pane.dart';
|
||||
import 'package:reboot_launcher/src/widget/home/profile.dart';
|
||||
|
||||
import 'package:reboot_launcher/src/controller/settings_controller.dart';
|
||||
import 'package:reboot_launcher/src/widget/os/border.dart';
|
||||
import 'package:reboot_launcher/src/widget/os/title_bar.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'hosting_page.dart';
|
||||
import 'info_page.dart';
|
||||
|
||||
const int pagesLength = 7;
|
||||
final RxInt pageIndex = RxInt(0);
|
||||
final Queue<int> _pagesStack = Queue();
|
||||
final List<GlobalKey> _pageKeys = List.generate(pagesLength, (index) => GlobalKey());
|
||||
GlobalKey get pageKey => _pageKeys[pageIndex.value];
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
const HomePage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> with WindowListener, AutomaticKeepAliveClientMixin {
|
||||
static const double _kDefaultPadding = 12.0;
|
||||
|
||||
final SettingsController _settingsController = Get.find<SettingsController>();
|
||||
final GlobalKey _searchKey = GlobalKey();
|
||||
final FocusNode _searchFocusNode = FocusNode();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final RxBool _focused = RxBool(true);
|
||||
final RxBool _fullScreen = RxBool(false);
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
windowManager.show();
|
||||
windowManager.addListener(this);
|
||||
_searchController.addListener(_onSearch);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void _onSearch() {
|
||||
// TODO: Implement
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
windowManager.removeListener(this);
|
||||
_searchFocusNode.dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowEnterFullScreen() {
|
||||
_fullScreen.value = true;
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowLeaveFullScreen() {
|
||||
_fullScreen.value = false;
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowFocus() {
|
||||
_focused.value = true;
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowBlur() {
|
||||
_focused.value = false;
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowResized() {
|
||||
_settingsController.saveWindowSize();
|
||||
super.onWindowResized();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowMoved() {
|
||||
windowManager.getPosition()
|
||||
.then((value) => _settingsController.saveWindowOffset(value));
|
||||
super.onWindowMoved();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return Stack(
|
||||
children: [
|
||||
Obx(() => NavigationPaneTheme(
|
||||
data: NavigationPaneThemeData(
|
||||
backgroundColor: FluentTheme.of(context).micaBackgroundColor.withOpacity(0.9),
|
||||
),
|
||||
child: NavigationView(
|
||||
paneBodyBuilder: (pane, body) => Padding(
|
||||
padding: const EdgeInsets.all(_kDefaultPadding),
|
||||
child: SizedBox(
|
||||
key: pageKey,
|
||||
child: body
|
||||
)
|
||||
),
|
||||
appBar: NavigationAppBar(
|
||||
height: 32,
|
||||
title: _draggableArea,
|
||||
actions: WindowTitleBar(focused: _focused()),
|
||||
leading: _backButton,
|
||||
automaticallyImplyLeading: false,
|
||||
),
|
||||
pane: NavigationPane(
|
||||
selected: pageIndex.value,
|
||||
onChanged: (index) {
|
||||
_pagesStack.add(pageIndex.value);
|
||||
pageIndex.value = index;
|
||||
},
|
||||
menuButton: const SizedBox(),
|
||||
displayMode: PaneDisplayMode.open,
|
||||
items: _items,
|
||||
header: const ProfileWidget(),
|
||||
autoSuggestBox: _autoSuggestBox,
|
||||
autoSuggestBoxReplacement: const Icon(FluentIcons.search),
|
||||
),
|
||||
contentShape: const RoundedRectangleBorder(),
|
||||
onOpenSearch: () => _searchFocusNode.requestFocus(),
|
||||
transitionBuilder: (child, animation) => child
|
||||
)
|
||||
)),
|
||||
if (isWin11)
|
||||
Obx(() => !_fullScreen.value && _focused.value ? const WindowBorder() : const SizedBox())
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
Widget get _backButton => Obx(() {
|
||||
pageIndex.value;
|
||||
return Button(
|
||||
style: ButtonStyle(
|
||||
padding: ButtonState.all(const EdgeInsets.only(top: 6.0)),
|
||||
backgroundColor: ButtonState.all(Colors.transparent),
|
||||
border: ButtonState.all(const BorderSide(color: Colors.transparent))
|
||||
),
|
||||
onPressed: _pagesStack.isEmpty ? null : () => pageIndex.value = _pagesStack.removeLast(),
|
||||
child: const Icon(FluentIcons.back, size: 12.0),
|
||||
);
|
||||
});
|
||||
|
||||
GestureDetector get _draggableArea => GestureDetector(
|
||||
onDoubleTap: () async => await windowManager.isMaximized() ? await windowManager.restore() : await windowManager.maximize(),
|
||||
onHorizontalDragStart: (event) => windowManager.startDragging(),
|
||||
onVerticalDragStart: (event) => windowManager.startDragging()
|
||||
);
|
||||
|
||||
Widget get _autoSuggestBox => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: TextBox(
|
||||
key: _searchKey,
|
||||
controller: _searchController,
|
||||
placeholder: 'Find a setting',
|
||||
focusNode: _searchFocusNode,
|
||||
autofocus: true,
|
||||
suffix: Button(
|
||||
onPressed: null,
|
||||
style: ButtonStyle(
|
||||
backgroundColor: ButtonState.all(Colors.transparent),
|
||||
border: ButtonState.all(const BorderSide(color: Colors.transparent))
|
||||
),
|
||||
child: Transform.flip(
|
||||
flipX: true,
|
||||
child: Icon(
|
||||
FluentIcons.search,
|
||||
size: 12.0,
|
||||
color: FluentTheme.of(context).resources.textFillColorPrimary
|
||||
),
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
|
||||
List<NavigationPaneItem> get _items => [
|
||||
RebootPaneItem(
|
||||
title: const Text("Play"),
|
||||
icon: SizedBox.square(
|
||||
dimension: 24,
|
||||
child: Image.asset("assets/images/play.png")
|
||||
),
|
||||
body: const PlayPage()
|
||||
),
|
||||
RebootPaneItem(
|
||||
title: const Text("Host"),
|
||||
icon: SizedBox.square(
|
||||
dimension: 24,
|
||||
child: Image.asset("assets/images/host.png")
|
||||
),
|
||||
body: const HostingPage()
|
||||
),
|
||||
RebootPaneItem(
|
||||
title: const Text("Server Browser"),
|
||||
icon: SizedBox.square(
|
||||
dimension: 24,
|
||||
child: Image.asset("assets/images/browse.png")
|
||||
),
|
||||
body: const BrowsePage()
|
||||
),
|
||||
RebootPaneItem(
|
||||
title: const Text("Authenticator"),
|
||||
icon: SizedBox.square(
|
||||
dimension: 24,
|
||||
child: Image.asset("assets/images/auth.png")
|
||||
),
|
||||
body: const AuthenticatorPage()
|
||||
),
|
||||
RebootPaneItem(
|
||||
title: const Text("Matchmaker"),
|
||||
icon: SizedBox.square(
|
||||
dimension: 24,
|
||||
child: Image.asset("assets/images/matchmaker.png")
|
||||
),
|
||||
body: const MatchmakerPage()
|
||||
),
|
||||
RebootPaneItem(
|
||||
title: const Text("Info"),
|
||||
icon: SizedBox.square(
|
||||
dimension: 24,
|
||||
child: Image.asset("assets/images/info.png")
|
||||
),
|
||||
body: const InfoPage()
|
||||
),
|
||||
RebootPaneItem(
|
||||
title: const Text("Settings"),
|
||||
icon: SizedBox.square(
|
||||
dimension: 24,
|
||||
child: Image.asset("assets/images/settings.png")
|
||||
),
|
||||
body: const SettingsPage()
|
||||
),
|
||||
];
|
||||
|
||||
String get searchValue => _searchController.text;
|
||||
}
|
||||
215
gui/lib/src/page/hosting_page.dart
Normal file
215
gui/lib/src/page/hosting_page.dart
Normal file
@@ -0,0 +1,215 @@
|
||||
import 'package:clipboard/clipboard.dart';
|
||||
import 'package:dart_ipify/dart_ipify.dart';
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_launcher/main.dart';
|
||||
import 'package:reboot_launcher/src/controller/game_controller.dart';
|
||||
import 'package:reboot_launcher/src/controller/hosting_controller.dart';
|
||||
import 'package:reboot_launcher/src/controller/update_controller.dart';
|
||||
import 'package:reboot_launcher/src/dialog/message.dart';
|
||||
import 'package:reboot_launcher/src/widget/common/setting_tile.dart';
|
||||
import 'package:flutter/material.dart' show Icons;
|
||||
|
||||
import 'package:reboot_common/common.dart';
|
||||
import 'package:reboot_launcher/src/widget/game/start_button.dart';
|
||||
import 'package:reboot_launcher/src/widget/version/version_selector.dart';
|
||||
|
||||
class HostingPage extends StatefulWidget {
|
||||
const HostingPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<HostingPage> createState() => _HostingPageState();
|
||||
}
|
||||
|
||||
class _HostingPageState extends State<HostingPage> with AutomaticKeepAliveClientMixin {
|
||||
final GameController _gameController = Get.find<GameController>();
|
||||
final HostingController _hostingController = Get.find<HostingController>();
|
||||
final UpdateController _updateController = Get.find<UpdateController>();
|
||||
late final RxBool _showPasswordTrailing = RxBool(_hostingController.password.text.isNotEmpty);
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
children: [
|
||||
Obx(() => Column(
|
||||
children: _updateController.status.value != UpdateStatus.error ? [] : [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: _updateError
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8.0
|
||||
),
|
||||
],
|
||||
)),
|
||||
SettingTile(
|
||||
title: "Game Server",
|
||||
subtitle: "Provide basic information about your server",
|
||||
expandedContent: [
|
||||
SettingTile(
|
||||
title: "Name",
|
||||
subtitle: "The name of your game server",
|
||||
isChild: true,
|
||||
content: TextFormBox(
|
||||
placeholder: "Name",
|
||||
controller: _hostingController.name
|
||||
)
|
||||
),
|
||||
SettingTile(
|
||||
title: "Description",
|
||||
subtitle: "The description of your game server",
|
||||
isChild: true,
|
||||
content: TextFormBox(
|
||||
placeholder: "Description",
|
||||
controller: _hostingController.description
|
||||
)
|
||||
),
|
||||
SettingTile(
|
||||
title: "Password",
|
||||
subtitle: "The password of your game server for the server browser",
|
||||
isChild: true,
|
||||
content: Obx(() => TextFormBox(
|
||||
placeholder: "Password",
|
||||
controller: _hostingController.password,
|
||||
autovalidateMode: AutovalidateMode.always,
|
||||
obscureText: !_hostingController.showPassword.value,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
onChanged: (text) => _showPasswordTrailing.value = text.isNotEmpty,
|
||||
suffix: Button(
|
||||
onPressed: () => _hostingController.showPassword.value = !_hostingController.showPassword.value,
|
||||
style: ButtonStyle(
|
||||
shape: ButtonState.all(const CircleBorder()),
|
||||
backgroundColor: ButtonState.all(Colors.transparent)
|
||||
),
|
||||
child: Icon(
|
||||
_hostingController.showPassword.value ? Icons.visibility_off : Icons.visibility,
|
||||
color: _showPasswordTrailing.value ? null : Colors.transparent
|
||||
),
|
||||
)
|
||||
))
|
||||
),
|
||||
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: 8.0,
|
||||
),
|
||||
const SettingTile(
|
||||
title: "Version",
|
||||
subtitle: "Select the version of Fortnite you want to host",
|
||||
content: 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,
|
||||
child: Text("Add build"),
|
||||
),
|
||||
isChild: true
|
||||
),
|
||||
SettingTile(
|
||||
title: "Download any version from the cloud",
|
||||
subtitle: "Download any Fortnite build easily from the cloud",
|
||||
content: Button(
|
||||
onPressed: VersionSelector.openDownloadDialog,
|
||||
child: Text("Download"),
|
||||
),
|
||||
isChild: true
|
||||
)
|
||||
]
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8.0
|
||||
),
|
||||
SettingTile(
|
||||
title: "Share",
|
||||
subtitle: "Make it easy for other people to join your server with the options in this section",
|
||||
expandedContent: [
|
||||
SettingTile(
|
||||
title: "Link",
|
||||
subtitle: "Copies a link for your server to the clipboard (requires the Reboot Launcher)",
|
||||
isChild: true,
|
||||
content: Button(
|
||||
onPressed: () async {
|
||||
FlutterClipboard.controlC("$kCustomUrlSchema://${_gameController.uuid}");
|
||||
showMessage(
|
||||
"Copied your link to the clipboard",
|
||||
severity: InfoBarSeverity.success
|
||||
);
|
||||
},
|
||||
child: const Text("Copy Link"),
|
||||
)
|
||||
),
|
||||
SettingTile(
|
||||
title: "Public IP",
|
||||
subtitle: "Copies your current public IP to the clipboard (doesn't require the Reboot Launcher)",
|
||||
isChild: true,
|
||||
content: Button(
|
||||
onPressed: () async {
|
||||
try {
|
||||
showMessage(
|
||||
"Obtaining your public IP...",
|
||||
loading: true,
|
||||
duration: null
|
||||
);
|
||||
var ip = await Ipify.ipv4();
|
||||
FlutterClipboard.controlC(ip);
|
||||
showMessage(
|
||||
"Copied your IP to the clipboard",
|
||||
severity: InfoBarSeverity.success
|
||||
);
|
||||
}catch(error) {
|
||||
showMessage(
|
||||
"An error occurred while obtaining your public IP: $error",
|
||||
severity: InfoBarSeverity.error,
|
||||
duration: snackbarLongDuration
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text("Copy IP"),
|
||||
)
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
const LaunchButton(
|
||||
host: true
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget get _updateError => MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: _updateController.update,
|
||||
child: const InfoBar(
|
||||
title: Text("The reboot dll couldn't be downloaded: click here to try again"),
|
||||
severity: InfoBarSeverity.info
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
133
gui/lib/src/page/info_page.dart
Normal file
133
gui/lib/src/page/info_page.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_launcher/src/widget/common/setting_tile.dart';
|
||||
|
||||
import 'package:reboot_launcher/src/controller/settings_controller.dart';
|
||||
|
||||
class InfoPage extends StatefulWidget {
|
||||
const InfoPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<InfoPage> createState() => _InfoPageState();
|
||||
}
|
||||
|
||||
class _InfoPageState extends State<InfoPage> with AutomaticKeepAliveClientMixin {
|
||||
final SettingsController _settingsController = Get.find<SettingsController>();
|
||||
late final ScrollController _controller;
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@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) {
|
||||
super.build(context);
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
children: [
|
||||
SettingTile(
|
||||
title: 'What is Project Reboot?',
|
||||
subtitle: 'Project Reboot allows anyone to easily host a game server for most of Fortnite\'s seasons. '
|
||||
'The project was started on Discord by Milxnor. '
|
||||
'The project is no longer being actively maintained.',
|
||||
titleStyle: FluentTheme
|
||||
.of(context)
|
||||
.typography
|
||||
.title,
|
||||
contentWidth: null,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
SettingTile(
|
||||
title: 'What is a game server?',
|
||||
subtitle: 'When you join a Fortnite Game, your client connects to a game server that allows you to play with others. '
|
||||
'You can join someone else\'s game server, or host one on your PC by going to the "Host" tab. ',
|
||||
titleStyle: FluentTheme
|
||||
.of(context)
|
||||
.typography
|
||||
.title,
|
||||
contentWidth: null,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
SettingTile(
|
||||
title: 'What is a client?',
|
||||
subtitle: 'A client is the actual Fortnite game. '
|
||||
'You can download any version of Fortnite from the launcher in the "Play" tab. '
|
||||
'You can also import versions from your local PC, but remember that these may be corrupted. '
|
||||
'If a local version doesn\'t work, try installing it from the launcher before reporting a bug.',
|
||||
titleStyle: FluentTheme
|
||||
.of(context)
|
||||
.typography
|
||||
.title,
|
||||
contentWidth: null,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
SettingTile(
|
||||
title: 'What is an authenticator?',
|
||||
subtitle: 'An authenticator is a program that handles authentication, parties and voice chats. '
|
||||
'By default, a LawinV1 server will be started for you to play. '
|
||||
'You can use also use an authenticator running locally(on your PC) or remotely(on another PC). '
|
||||
'Changing the authenticator settings can break the client and game server: unless you are an advanced user, do not edit, for any reason, these settings! '
|
||||
'If you need to restore these settings, go to the "Settings" tab and click on "Restore Defaults". ',
|
||||
titleStyle: FluentTheme
|
||||
.of(context)
|
||||
.typography
|
||||
.title,
|
||||
contentWidth: null,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
SettingTile(
|
||||
title: 'Do I need to update DLLs?',
|
||||
subtitle: 'No, all the files that the launcher uses are automatically updated. '
|
||||
'You can use your own DLLs by going to the "Settings" tab, but make sure that they don\'t create a console that reads IO or the launcher will stop working correctly. '
|
||||
'Unless you are an advanced user, changing these options is not recommended',
|
||||
titleStyle: FluentTheme
|
||||
.of(context)
|
||||
.typography
|
||||
.title,
|
||||
contentWidth: null,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
SettingTile(
|
||||
title: 'Where can I report bugs or ask for new features?',
|
||||
subtitle: 'Go to the "Settings" tab and click on report bug. '
|
||||
'Please make sure to be as specific as possible when filing a report as it\'s crucial to make it as easy to fix/implement',
|
||||
titleStyle: FluentTheme
|
||||
.of(context)
|
||||
.typography
|
||||
.title,
|
||||
contentWidth: null,
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
139
gui/lib/src/page/matchmaker_page.dart
Normal file
139
gui/lib/src/page/matchmaker_page.dart
Normal file
@@ -0,0 +1,139 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_common/common.dart';
|
||||
import 'package:reboot_launcher/src/controller/matchmaker_controller.dart';
|
||||
import 'package:reboot_launcher/src/widget/server/type_selector.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'package:reboot_launcher/src/widget/common/setting_tile.dart';
|
||||
|
||||
import 'package:reboot_launcher/src/dialog/dialog.dart';
|
||||
import 'package:reboot_launcher/src/dialog/dialog_button.dart';
|
||||
import 'package:reboot_launcher/src/widget/server/start_button.dart';
|
||||
|
||||
class MatchmakerPage extends StatefulWidget {
|
||||
const MatchmakerPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<MatchmakerPage> createState() => _MatchmakerPageState();
|
||||
}
|
||||
|
||||
class _MatchmakerPageState extends State<MatchmakerPage> with AutomaticKeepAliveClientMixin {
|
||||
final MatchmakerController _matchmakerController = Get.find<MatchmakerController>();
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
children: [
|
||||
Obx(() => SettingTile(
|
||||
title: "Matchmaker configuration",
|
||||
subtitle: "This section contains the matchmaker's configuration",
|
||||
content: const ServerTypeSelector(
|
||||
authenticator: false
|
||||
),
|
||||
expandedContent: [
|
||||
if(_matchmakerController.type.value == ServerType.remote)
|
||||
SettingTile(
|
||||
title: "Host",
|
||||
subtitle: "The hostname of the matchmaker",
|
||||
isChild: true,
|
||||
content: TextFormBox(
|
||||
placeholder: "Host",
|
||||
controller: _matchmakerController.host,
|
||||
readOnly: _matchmakerController.type.value != ServerType.remote
|
||||
)
|
||||
),
|
||||
if(_matchmakerController.type.value != ServerType.embedded)
|
||||
SettingTile(
|
||||
title: "Port",
|
||||
subtitle: "The port of the matchmaker",
|
||||
isChild: true,
|
||||
content: TextFormBox(
|
||||
placeholder: "Port",
|
||||
controller: _matchmakerController.port,
|
||||
readOnly: _matchmakerController.type.value != ServerType.remote
|
||||
)
|
||||
),
|
||||
if(_matchmakerController.type.value == ServerType.embedded)
|
||||
SettingTile(
|
||||
title: "Game server address",
|
||||
subtitle: "The address of the game server used by the matchmaker",
|
||||
isChild: true,
|
||||
content: TextFormBox(
|
||||
placeholder: "Address",
|
||||
controller: _matchmakerController.gameServerAddress
|
||||
)
|
||||
),
|
||||
if(_matchmakerController.type.value == ServerType.embedded)
|
||||
SettingTile(
|
||||
title: "Detached",
|
||||
subtitle: "Whether the embedded matchmaker should be started as a separate process, useful for debugging",
|
||||
contentWidth: null,
|
||||
isChild: true,
|
||||
content: Obx(() => ToggleSwitch(
|
||||
checked: _matchmakerController.detached.value,
|
||||
onChanged: (value) => _matchmakerController.detached.value = value
|
||||
)),
|
||||
)
|
||||
]
|
||||
)),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
SettingTile(
|
||||
title: "Installation directory",
|
||||
subtitle: "Opens the folder where the embedded matchmaker is located",
|
||||
content: Button(
|
||||
onPressed: () => launchUrl(authenticatorDirectory.uri),
|
||||
child: const Text("Show Files")
|
||||
)
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
SettingTile(
|
||||
title: "Reset matchmaker",
|
||||
subtitle: "Resets the authenticator's settings to their default values",
|
||||
content: Button(
|
||||
onPressed: () => showDialog(
|
||||
builder: (context) => InfoDialog(
|
||||
text: "Do you want to reset all the setting in this tab to their default values? This action is irreversible",
|
||||
buttons: [
|
||||
DialogButton(
|
||||
type: ButtonType.secondary,
|
||||
text: "Close",
|
||||
),
|
||||
DialogButton(
|
||||
type: ButtonType.primary,
|
||||
text: "Reset",
|
||||
onTap: () {
|
||||
_matchmakerController.reset();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
),
|
||||
child: const Text("Reset"),
|
||||
)
|
||||
)
|
||||
]
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
const ServerButton(
|
||||
authenticator: false
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
109
gui/lib/src/page/play_page.dart
Normal file
109
gui/lib/src/page/play_page.dart
Normal file
@@ -0,0 +1,109 @@
|
||||
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_common/common.dart';
|
||||
import 'package:reboot_launcher/src/controller/hosting_controller.dart';
|
||||
import 'package:reboot_launcher/src/controller/matchmaker_controller.dart';
|
||||
import 'package:reboot_launcher/src/page/home_page.dart';
|
||||
import 'package:reboot_launcher/src/widget/game/start_button.dart';
|
||||
import 'package:reboot_launcher/src/widget/common/setting_tile.dart';
|
||||
import 'package:reboot_launcher/src/widget/version/version_selector.dart';
|
||||
|
||||
|
||||
class PlayPage extends StatefulWidget {
|
||||
const PlayPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<PlayPage> createState() => _PlayPageState();
|
||||
}
|
||||
|
||||
class _PlayPageState extends State<PlayPage> {
|
||||
final MatchmakerController _matchmakerController = Get.find<MatchmakerController>();
|
||||
final HostingController _hostingController = Get.find<HostingController>();
|
||||
late final RxBool _selfServer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_selfServer = RxBool(_isLocalPlay);
|
||||
_matchmakerController.gameServerAddress.addListener(() => _selfServer.value = _isLocalPlay);
|
||||
_hostingController.started.listen((_) => _selfServer.value = _isLocalPlay);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
bool get _isLocalPlay => isLocalHost(_matchmakerController.gameServerAddress.text)
|
||||
&& !_hostingController.started.value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
children: [
|
||||
const SettingTile(
|
||||
title: "Version",
|
||||
subtitle: "Select the version of Fortnite you want to host",
|
||||
content: 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,
|
||||
child: Text("Add build"),
|
||||
),
|
||||
isChild: true
|
||||
),
|
||||
SettingTile(
|
||||
title: "Download any version from the cloud",
|
||||
subtitle: "Download any Fortnite build easily from the cloud",
|
||||
content: Button(
|
||||
onPressed: VersionSelector.openDownloadDialog,
|
||||
child: Text("Download"),
|
||||
),
|
||||
isChild: true
|
||||
)
|
||||
]
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
SettingTile(
|
||||
title: "Game Server",
|
||||
subtitle: "Helpful shortcuts to find the server where you want to play",
|
||||
expandedContent: [
|
||||
SettingTile(
|
||||
title: "Host a server",
|
||||
subtitle: "Do you want to play with your friends? Host a server for them!",
|
||||
content: Button(
|
||||
onPressed: () => pageIndex.value = 1,
|
||||
child: const Text("Host")
|
||||
),
|
||||
isChild: true
|
||||
),
|
||||
SettingTile(
|
||||
title: "Join a server",
|
||||
subtitle: "Find a server where you can play on the launcher's server browser",
|
||||
content: Button(
|
||||
onPressed: () => pageIndex.value = 2,
|
||||
child: const Text("Browse")
|
||||
),
|
||||
isChild: true
|
||||
)
|
||||
]
|
||||
),
|
||||
],
|
||||
)
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
const LaunchButton(
|
||||
startLabel: 'Launch Fortnite',
|
||||
stopLabel: 'Close Fortnite',
|
||||
host: false
|
||||
)
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
182
gui/lib/src/page/settings_page.dart
Normal file
182
gui/lib/src/page/settings_page.dart
Normal file
@@ -0,0 +1,182 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_common/common.dart';
|
||||
import 'package:reboot_launcher/src/controller/build_controller.dart';
|
||||
import 'package:reboot_launcher/src/controller/game_controller.dart';
|
||||
import 'package:reboot_launcher/src/controller/hosting_controller.dart';
|
||||
import 'package:reboot_launcher/src/controller/authenticator_controller.dart';
|
||||
import 'package:reboot_launcher/src/controller/settings_controller.dart';
|
||||
import 'package:reboot_launcher/src/controller/update_controller.dart';
|
||||
import 'package:reboot_launcher/src/dialog/dialog_button.dart';
|
||||
import 'package:reboot_launcher/src/widget/common/file_selector.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'package:reboot_launcher/src/util/checks.dart';
|
||||
import 'package:reboot_launcher/src/dialog/dialog.dart';
|
||||
import 'package:reboot_launcher/src/widget/common/setting_tile.dart';
|
||||
|
||||
class SettingsPage extends StatefulWidget {
|
||||
const SettingsPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<SettingsPage> createState() => _SettingsPageState();
|
||||
}
|
||||
|
||||
class _SettingsPageState extends State<SettingsPage> with AutomaticKeepAliveClientMixin {
|
||||
final BuildController _buildController = Get.find<BuildController>();
|
||||
final GameController _gameController = Get.find<GameController>();
|
||||
final HostingController _hostingController = Get.find<HostingController>();
|
||||
final AuthenticatorController _authenticatorController = Get.find<AuthenticatorController>();
|
||||
final SettingsController _settingsController = Get.find<SettingsController>();
|
||||
final UpdateController _updateController = Get.find<UpdateController>();
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return ListView(
|
||||
children: [
|
||||
SettingTile(
|
||||
title: "Client settings",
|
||||
subtitle: "This section contains the dlls used to make the Fortnite client work",
|
||||
expandedContent: [
|
||||
_createFileSetting(
|
||||
title: "Unreal engine console",
|
||||
description: "This file is injected to unlock the Unreal Engine Console",
|
||||
controller: _settingsController.consoleDll
|
||||
),
|
||||
_createFileSetting(
|
||||
title: "Authentication patcher",
|
||||
description: "This file is injected to redirect all HTTP requests to the launcher's authenticator",
|
||||
controller: _settingsController.authDll
|
||||
),
|
||||
SettingTile(
|
||||
title: "Custom launch arguments",
|
||||
subtitle: "Additional arguments to use when launching the game",
|
||||
isChild: true,
|
||||
content: TextFormBox(
|
||||
placeholder: "Arguments...",
|
||||
controller: _gameController.customLaunchArgs,
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
SettingTile(
|
||||
title: "Server settings",
|
||||
subtitle: "This section contains settings related to the game server implementation",
|
||||
expandedContent: [
|
||||
_createFileSetting(
|
||||
title: "Game server",
|
||||
description: "This file is injected to create a game server & host matches",
|
||||
controller: _settingsController.rebootDll
|
||||
),
|
||||
SettingTile(
|
||||
title: "Update mirror",
|
||||
subtitle: "The URL used to update the game server dll",
|
||||
content: TextFormBox(
|
||||
placeholder: "URL",
|
||||
controller: _updateController.url,
|
||||
validator: checkUpdateUrl
|
||||
),
|
||||
isChild: true
|
||||
),
|
||||
SettingTile(
|
||||
title: "Update timer",
|
||||
subtitle: "Determines when the game server dll should be updated",
|
||||
content: Obx(() => DropDownButton(
|
||||
leading: Text(_updateController.timer.value.text),
|
||||
items: UpdateTimer.values.map((entry) => MenuFlyoutItem(
|
||||
text: Text(entry.text),
|
||||
onPressed: () => _updateController.timer.value = entry
|
||||
)).toList()
|
||||
)),
|
||||
isChild: true
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
SettingTile(
|
||||
title: "Launcher utilities",
|
||||
subtitle: "This section contains handy settings for the launcher",
|
||||
expandedContent: [
|
||||
SettingTile(
|
||||
title: "Installation directory",
|
||||
subtitle: "Opens the installation directory",
|
||||
isChild: true,
|
||||
content: Button(
|
||||
onPressed: () => launchUrl(installationDirectory.uri),
|
||||
child: const Text("Show Files"),
|
||||
)
|
||||
),
|
||||
SettingTile(
|
||||
title: "Create a bug report",
|
||||
subtitle: "Help me fix bugs by reporting them",
|
||||
isChild: true,
|
||||
content: Button(
|
||||
onPressed: () => launchUrl(Uri.parse("https://github.com/Auties00/reboot_launcher/issues")),
|
||||
child: const Text("Report a bug"),
|
||||
)
|
||||
),
|
||||
SettingTile(
|
||||
title: "Reset settings",
|
||||
subtitle: "Resets the launcher's settings to their default values",
|
||||
isChild: true,
|
||||
content: Button(
|
||||
onPressed: () => showDialog(
|
||||
builder: (context) => InfoDialog(
|
||||
text: "Do you want to reset all the launcher's settings to their default values? This action is irreversible",
|
||||
buttons: [
|
||||
DialogButton(
|
||||
type: ButtonType.secondary,
|
||||
text: "Close",
|
||||
),
|
||||
DialogButton(
|
||||
type: ButtonType.primary,
|
||||
text: "Reset",
|
||||
onTap: () {
|
||||
_buildController.reset();
|
||||
_gameController.reset();
|
||||
_hostingController.reset();
|
||||
_authenticatorController.reset();
|
||||
_settingsController.reset();
|
||||
_updateController.reset();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
),
|
||||
child: const Text("Reset"),
|
||||
)
|
||||
)
|
||||
],
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
Widget _createFileSetting({required String title, required String description, required TextEditingController controller}) => SettingTile(
|
||||
title: title,
|
||||
subtitle: description,
|
||||
content: FileSelector(
|
||||
placeholder: "Path",
|
||||
windowTitle: "Select a file",
|
||||
controller: controller,
|
||||
validator: checkDll,
|
||||
extension: "dll",
|
||||
folder: false
|
||||
),
|
||||
isChild: true
|
||||
);
|
||||
}
|
||||
|
||||
extension _UpdateTimerExtension on UpdateTimer {
|
||||
String get text => this == UpdateTimer.never ? "Never" : "Every $name";
|
||||
}
|
||||
85
gui/lib/src/util/checks.dart
Normal file
85
gui/lib/src/util/checks.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:reboot_common/common.dart';
|
||||
|
||||
String? checkVersion(String? text, List<FortniteVersion> versions) {
|
||||
if (text == null || text.isEmpty) {
|
||||
return 'Empty version name';
|
||||
}
|
||||
|
||||
if (versions.any((element) => element.name == text)) {
|
||||
return 'This version already exists';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String? checkChangeVersion(String? text) {
|
||||
if (text == null || text.isEmpty) {
|
||||
return 'Empty version name';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String? checkGameFolder(text) {
|
||||
if (text == null || text.isEmpty) {
|
||||
return 'Empty game path';
|
||||
}
|
||||
|
||||
var directory = Directory(text);
|
||||
if (!directory.existsSync()) {
|
||||
return "Directory doesn't exist";
|
||||
}
|
||||
|
||||
if (FortniteVersionExtension.findExecutable(directory, "FortniteClient-Win64-Shipping.exe") == null) {
|
||||
return "Invalid game path";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String? checkDownloadDestination(text) {
|
||||
if (text == null || text.isEmpty) {
|
||||
return 'Invalid download path';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String? checkDll(String? text) {
|
||||
if (text == null || text.isEmpty) {
|
||||
return "Empty dll path";
|
||||
}
|
||||
|
||||
if (!File(text).existsSync()) {
|
||||
return "This dll doesn't exist";
|
||||
}
|
||||
|
||||
if (!text.endsWith(".dll")) {
|
||||
return "This file is not a dll";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String? checkMatchmaking(String? text) {
|
||||
if (text == null || text.isEmpty) {
|
||||
return "Empty hostname";
|
||||
}
|
||||
|
||||
var ipParts = text.split(":");
|
||||
if(ipParts.length > 2){
|
||||
return "Wrong format, expected ip:port";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String? checkUpdateUrl(String? text) {
|
||||
if (text == null || text.isEmpty) {
|
||||
return "Empty URL";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
52
gui/lib/src/util/cryptography.dart
Normal file
52
gui/lib/src/util/cryptography.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
import 'package:bcrypt/bcrypt.dart';
|
||||
import 'package:pointycastle/export.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
const int _ivLength = 16;
|
||||
const int _keyLength = 32;
|
||||
|
||||
String hashPassword(String plaintext) => BCrypt.hashpw(plaintext, BCrypt.gensalt());
|
||||
|
||||
bool checkPassword(String password, String hashedText) => BCrypt.checkpw(password, hashedText);
|
||||
|
||||
String aes256Encrypt(String plainText, String password) {
|
||||
final random = Random.secure();
|
||||
final iv = Uint8List.fromList(List.generate(_ivLength, (index) => random.nextInt(256)));
|
||||
final keyDerivationData = Uint8List.fromList(utf8.encode(password));
|
||||
final derive = PBKDF2KeyDerivator(HMac(SHA256Digest(), _ivLength * 8));
|
||||
var params = Pbkdf2Parameters(iv, _ivLength * 8, _keyLength);
|
||||
derive.init(params);
|
||||
final key = derive.process(keyDerivationData);
|
||||
final cipherParams = PaddedBlockCipherParameters(
|
||||
KeyParameter(key),
|
||||
null,
|
||||
);
|
||||
final aes = AESEngine();
|
||||
final paddingCipher = PaddedBlockCipherImpl(PKCS7Padding(), aes);
|
||||
paddingCipher.init(true, cipherParams);
|
||||
final plainBytes = Uint8List.fromList(utf8.encode(plainText));
|
||||
final encryptedBytes = paddingCipher.process(plainBytes);
|
||||
return base64.encode([...iv, ...encryptedBytes]);
|
||||
}
|
||||
|
||||
String aes256Decrypt(String encryptedText, String password) {
|
||||
final encryptedBytes = base64.decode(encryptedText);
|
||||
final salt = encryptedBytes.sublist(0, _ivLength);
|
||||
final payload = encryptedBytes.sublist(_ivLength);
|
||||
final keyDerivationData = Uint8List.fromList(utf8.encode(password));
|
||||
final derive = PBKDF2KeyDerivator(HMac(SHA256Digest(), _ivLength * 8));
|
||||
var params = Pbkdf2Parameters(salt, _ivLength * 8, _keyLength);
|
||||
derive.init(params);
|
||||
final key = derive.process(keyDerivationData);
|
||||
final cipherParams = PaddedBlockCipherParameters(
|
||||
KeyParameter(key),
|
||||
null,
|
||||
);
|
||||
final aes = AESEngine();
|
||||
final paddingCipher = PaddedBlockCipherImpl(PKCS7Padding(), aes);
|
||||
paddingCipher.init(false, cipherParams);
|
||||
final decryptedBytes = paddingCipher.process(payload);
|
||||
return utf8.decode(decryptedBytes);
|
||||
}
|
||||
13
gui/lib/src/util/os.dart
Normal file
13
gui/lib/src/util/os.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'dart:io';
|
||||
|
||||
final RegExp _winBuildRegex = RegExp(r'(?<=\(Build )(.*)(?=\))');
|
||||
|
||||
bool get isWin11 {
|
||||
var result = _winBuildRegex.firstMatch(Platform.operatingSystemVersion)?.group(1);
|
||||
if(result == null){
|
||||
return false;
|
||||
}
|
||||
|
||||
var intBuild = int.tryParse(result);
|
||||
return intBuild != null && intBuild > 22000;
|
||||
}
|
||||
17
gui/lib/src/util/picker.dart
Normal file
17
gui/lib/src/util/picker.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
|
||||
Future<String?> openFolderPicker(String title) async =>
|
||||
await FilePicker.platform.getDirectoryPath(dialogTitle: title);
|
||||
|
||||
Future<String?> openFilePicker(String extension) async {
|
||||
var result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowMultiple: false,
|
||||
allowedExtensions: [extension]
|
||||
);
|
||||
if(result == null || result.files.isEmpty){
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.files.first.path;
|
||||
}
|
||||
48
gui/lib/src/util/watch.dart
Normal file
48
gui/lib/src/util/watch.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_common/common.dart';
|
||||
import 'package:reboot_launcher/src/controller/game_controller.dart';
|
||||
import 'package:reboot_launcher/src/controller/hosting_controller.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
final SupabaseClient _supabase = Supabase.instance.client;
|
||||
final GameController _gameController = Get.find<GameController>();
|
||||
final HostingController _hostingController = Get.find<HostingController>();
|
||||
|
||||
extension GameInstanceWatcher on GameInstance {
|
||||
Future<void> startObserver() async {
|
||||
if(watchPid != null) {
|
||||
Process.killPid(watchPid!, ProcessSignal.sigabrt);
|
||||
}
|
||||
|
||||
watchProcess(gamePid).then((value) async {
|
||||
if(hosting) {
|
||||
_onHostingStopped();
|
||||
}
|
||||
|
||||
_onGameStopped();
|
||||
});
|
||||
|
||||
watchPid = startBackgroundProcess(
|
||||
'${assetsDirectory.path}\\misc\\watch.exe',
|
||||
[_gameController.uuid, gamePid.toString(), launcherPid?.toString() ?? "-1", eacPid?.toString() ?? "-1", hosting.toString()]
|
||||
);
|
||||
}
|
||||
|
||||
void _onGameStopped() {
|
||||
_gameController.started.value = false;
|
||||
_gameController.instance.value?.kill();
|
||||
if(linkedHosting) {
|
||||
_onHostingStopped();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onHostingStopped() async {
|
||||
_hostingController.started.value = false;
|
||||
_hostingController.instance.value?.kill();
|
||||
await _supabase.from('hosts')
|
||||
.delete()
|
||||
.match({'id': _gameController.uuid});
|
||||
}
|
||||
}
|
||||
75
gui/lib/src/widget/common/file_selector.dart
Normal file
75
gui/lib/src/widget/common/file_selector.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:reboot_launcher/src/dialog/message.dart';
|
||||
import 'package:reboot_launcher/src/util/picker.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 => TextFormBox(
|
||||
controller: widget.controller,
|
||||
placeholder: widget.placeholder,
|
||||
validator: widget.validator,
|
||||
autovalidateMode: widget.validatorMode ?? AutovalidateMode.onUserInteraction,
|
||||
suffix: !widget.allowNavigator ? null : 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);
|
||||
}
|
||||
}
|
||||
133
gui/lib/src/widget/common/setting_tile.dart
Normal file
133
gui/lib/src/widget/common/setting_tile.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
import 'package:auto_animated_list/auto_animated_list.dart';
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
import 'package:skeletons/skeletons.dart';
|
||||
|
||||
class SettingTile extends StatefulWidget {
|
||||
static const double kDefaultContentWidth = 200.0;
|
||||
static const double kDefaultSpacing = 8.0;
|
||||
static const double kDefaultHeaderHeight = 72;
|
||||
|
||||
final String? title;
|
||||
final TextStyle? titleStyle;
|
||||
final String? subtitle;
|
||||
final TextStyle? subtitleStyle;
|
||||
final Widget? content;
|
||||
final double? contentWidth;
|
||||
final List<Widget>? expandedContent;
|
||||
final double expandedContentHeaderHeight;
|
||||
final double expandedContentSpacing;
|
||||
final bool isChild;
|
||||
|
||||
const SettingTile(
|
||||
{Key? key,
|
||||
this.title,
|
||||
this.titleStyle,
|
||||
this.subtitle,
|
||||
this.subtitleStyle,
|
||||
this.content,
|
||||
this.contentWidth = kDefaultContentWidth,
|
||||
this.expandedContentHeaderHeight = kDefaultHeaderHeight,
|
||||
this.expandedContentSpacing = kDefaultSpacing,
|
||||
this.expandedContent,
|
||||
this.isChild = false})
|
||||
: assert(
|
||||
(title == null && subtitle == null) ||
|
||||
(title != null && subtitle != null),
|
||||
"Title and subtitle can only be null together"),
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
State<SettingTile> createState() => _SettingTileState();
|
||||
}
|
||||
|
||||
class _SettingTileState extends State<SettingTile> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.expandedContent == null || widget.expandedContent?.isEmpty == true) {
|
||||
return _contentCard;
|
||||
}
|
||||
|
||||
return Expander(
|
||||
initiallyExpanded: true,
|
||||
headerShape: (open) => const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(4.0)),
|
||||
),
|
||||
header: SizedBox(
|
||||
height: widget.expandedContentHeaderHeight,
|
||||
child: _buildTile(false)
|
||||
),
|
||||
trailing: _trailing,
|
||||
content: _expandedContent
|
||||
);
|
||||
}
|
||||
|
||||
Widget get _expandedContent {
|
||||
var expandedContents = widget.expandedContent!;
|
||||
var separatedContents = List.generate(expandedContents.length * 2, (index) => index % 2 == 0 ? expandedContents[index ~/ 2] : SizedBox(height: widget.expandedContentSpacing));
|
||||
return AutoAnimatedList<Widget>(
|
||||
scrollDirection: Axis.vertical,
|
||||
shrinkWrap: true,
|
||||
items: separatedContents,
|
||||
itemBuilder: (context, child, index, animation) => FadeTransition(
|
||||
opacity: animation,
|
||||
child: child
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Widget get _trailing =>
|
||||
SizedBox(width: widget.contentWidth, child: widget.content);
|
||||
|
||||
Widget get _contentCard {
|
||||
if (widget.isChild) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: _buildTile(true)
|
||||
);
|
||||
}
|
||||
|
||||
return Card(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(4.0)),
|
||||
child: _buildTile(true)
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTile(bool trailing) {
|
||||
return ListTile(
|
||||
title: widget.title == null ? _skeletonTitle : _title,
|
||||
subtitle: widget.title == null ? _skeletonSubtitle : _subtitle,
|
||||
trailing: trailing ? _trailing : null
|
||||
);
|
||||
}
|
||||
|
||||
Widget get _title => Text(
|
||||
widget.title!,
|
||||
style:
|
||||
widget.titleStyle ?? FluentTheme.of(context).typography.subtitle,
|
||||
);
|
||||
|
||||
Widget get _skeletonTitle => const SkeletonLine(
|
||||
style: SkeletonLineStyle(
|
||||
padding: EdgeInsets.only(
|
||||
right: 24.0
|
||||
),
|
||||
height: 18
|
||||
),
|
||||
);
|
||||
|
||||
Widget get _subtitle => Text(
|
||||
widget.subtitle!,
|
||||
style: widget.subtitleStyle ?? FluentTheme.of(context).typography.body
|
||||
);
|
||||
|
||||
Widget get _skeletonSubtitle => const SkeletonLine(
|
||||
style: SkeletonLineStyle(
|
||||
padding: EdgeInsets.only(
|
||||
top: 8.0,
|
||||
bottom: 8.0,
|
||||
right: 24.0
|
||||
),
|
||||
height: 13
|
||||
)
|
||||
);
|
||||
}
|
||||
420
gui/lib/src/widget/game/start_button.dart
Normal file
420
gui/lib/src/widget/game/start_button.dart
Normal file
@@ -0,0 +1,420 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dart_ipify/dart_ipify.dart';
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
import 'package:get/get.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:process_run/shell.dart';
|
||||
import 'package:reboot_common/common.dart';
|
||||
import 'package:reboot_launcher/src/controller/game_controller.dart';
|
||||
import 'package:reboot_launcher/src/controller/hosting_controller.dart';
|
||||
import 'package:reboot_launcher/src/controller/authenticator_controller.dart';
|
||||
import 'package:reboot_launcher/src/controller/settings_controller.dart';
|
||||
import 'package:reboot_launcher/src/interactive/game.dart';
|
||||
import 'package:reboot_launcher/src/interactive/server.dart';
|
||||
import 'package:reboot_launcher/src/dialog/message.dart';
|
||||
import 'package:reboot_launcher/src/util/cryptography.dart';
|
||||
import 'package:reboot_launcher/src/util/watch.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
class LaunchButton extends StatefulWidget {
|
||||
final bool host;
|
||||
final String? startLabel;
|
||||
final String? stopLabel;
|
||||
final bool Function()? onTap;
|
||||
|
||||
const LaunchButton({Key? key, required this.host, this.startLabel, this.stopLabel, this.onTap}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<LaunchButton> createState() => _LaunchButtonState();
|
||||
}
|
||||
|
||||
class _LaunchButtonState extends State<LaunchButton> {
|
||||
final GameController _gameController = Get.find<GameController>();
|
||||
final HostingController _hostingController = Get.find<HostingController>();
|
||||
final AuthenticatorController _authenticatorController = Get.find<AuthenticatorController>();
|
||||
final SettingsController _settingsController = Get.find<SettingsController>();
|
||||
final File _logFile = File("${logsDirectory.path}\\game.log");
|
||||
Completer<bool> _completer = Completer();
|
||||
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.startLabel ?? (widget.host ? "Start hosting" : "Launch fortnite");
|
||||
|
||||
String get _stopMessage => widget.stopLabel ?? (widget.host ? "Stop hosting" : "Close fortnite");
|
||||
|
||||
Future<void> _start() async {
|
||||
if(widget.onTap != null && !widget.onTap!()){
|
||||
return;
|
||||
}
|
||||
|
||||
if (_hasStarted) {
|
||||
_onStop(widget.host);
|
||||
return;
|
||||
}
|
||||
|
||||
if(_gameController.selectedVersion == null){
|
||||
showMessage("Select a Fortnite version before continuing");
|
||||
_onStop(widget.host);
|
||||
return;
|
||||
}
|
||||
|
||||
_setStarted(widget.host, true);
|
||||
for (var element in Injectable.values) {
|
||||
if(await _getDllPath(element, widget.host) == null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
var version = _gameController.selectedVersion!;
|
||||
var executable = await version.executable;
|
||||
if(executable == null){
|
||||
showMissingBuildError(version);
|
||||
_onStop(widget.host);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = _authenticatorController.started() || await _authenticatorController.toggleInteractive(false);
|
||||
if(!result){
|
||||
_onStop(widget.host);
|
||||
return;
|
||||
}
|
||||
|
||||
var automaticallyStartedServer = await _startMatchMakingServer();
|
||||
await _startGameProcesses(version, widget.host, automaticallyStartedServer);
|
||||
if(widget.host){
|
||||
await _showServerLaunchingWarning();
|
||||
}
|
||||
} catch (exception, stacktrace) {
|
||||
_closeLaunchingWidget(false);
|
||||
_onStop(widget.host);
|
||||
showCorruptedBuildError(widget.host, exception, stacktrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startGameProcesses(FortniteVersion version, bool host, bool linkedHosting) async {
|
||||
_setStarted(host, true);
|
||||
var launcherProcess = await _createLauncherProcess(version);
|
||||
var eacProcess = await _createEacProcess(version);
|
||||
var executable = await version.executable;
|
||||
if(executable == null){
|
||||
showMissingBuildError(version);
|
||||
_onStop(widget.host);
|
||||
return;
|
||||
}
|
||||
|
||||
var gameProcess = await _createGameProcess(executable.path, host);
|
||||
var instance = GameInstance(gameProcess, launcherProcess, eacProcess, host, linkedHosting);
|
||||
instance.startObserver();
|
||||
if(host){
|
||||
_hostingController.instance.value = instance;
|
||||
}else{
|
||||
_gameController.instance.value = instance;
|
||||
}
|
||||
_injectOrShowError(Injectable.sslBypass, host);
|
||||
}
|
||||
|
||||
Future<bool> _startMatchMakingServer() async {
|
||||
if(widget.host){
|
||||
return false;
|
||||
}
|
||||
|
||||
// var matchmakingIp = _settingsController.matchmakingIp.text;
|
||||
var matchmakingIp = "127.0.0.1";
|
||||
if(!isLocalHost(matchmakingIp)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if(!_gameController.autoStartGameServer()){
|
||||
return false;
|
||||
}
|
||||
|
||||
if(_hostingController.started()){
|
||||
return false;
|
||||
}
|
||||
|
||||
var version = _gameController.selectedVersion!;
|
||||
await _startGameProcesses(version, true, false);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<int> _createGameProcess(String gamePath, bool host) async {
|
||||
var gameArgs = createRebootArgs(_safeUsername, _gameController.password.text, 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.pid;
|
||||
}
|
||||
|
||||
String get _safeUsername {
|
||||
if (_gameController.username.text.isEmpty) {
|
||||
return kDefaultPlayerName;
|
||||
}
|
||||
|
||||
var username = _gameController.username.text;
|
||||
if(_gameController.password.text.isNotEmpty){
|
||||
return username;
|
||||
}
|
||||
|
||||
username = _gameController.username.text.replaceAll(RegExp("[^A-Za-z0-9]"), "").trim();
|
||||
if(username.isEmpty){
|
||||
return kDefaultPlayerName;
|
||||
}
|
||||
|
||||
return username;
|
||||
}
|
||||
|
||||
Future<int?> _createLauncherProcess(FortniteVersion version) async {
|
||||
var launcherFile = version.launcher;
|
||||
if (launcherFile == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var launcherProcess = await Process.start(launcherFile.path, []);
|
||||
var pid = launcherProcess.pid;
|
||||
suspend(pid);
|
||||
return pid;
|
||||
}
|
||||
|
||||
Future<int?> _createEacProcess(FortniteVersion version) async {
|
||||
var eacFile = version.eacExecutable;
|
||||
if (eacFile == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var eacProcess = await Process.start(eacFile.path, []);
|
||||
var pid = eacProcess.pid;
|
||||
suspend(pid);
|
||||
return pid;
|
||||
}
|
||||
|
||||
void _onEnd() {
|
||||
if(_fail){
|
||||
return;
|
||||
}
|
||||
|
||||
_closeLaunchingWidget(false);
|
||||
_onStop(widget.host);
|
||||
}
|
||||
|
||||
void _closeLaunchingWidget(bool success) {
|
||||
showMessage(
|
||||
success ? "The headless server was started successfully" : "An error occurred while starting the headless server",
|
||||
severity: success ? InfoBarSeverity.success : InfoBarSeverity.error
|
||||
);
|
||||
if(!_completer.isCompleted) {
|
||||
_completer.complete(success);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showServerLaunchingWarning() async {
|
||||
showMessage(
|
||||
"Launching headless server...",
|
||||
loading: true,
|
||||
duration: null
|
||||
);
|
||||
var result = await _completer.future;
|
||||
if(!result){
|
||||
_onStop(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if(!_hostingController.discoverable.value){
|
||||
return;
|
||||
}
|
||||
|
||||
var password = _hostingController.password.text;
|
||||
var hasPassword = password.isNotEmpty;
|
||||
var ip = await Ipify.ipv4();
|
||||
if(hasPassword) {
|
||||
ip = aes256Encrypt(ip, password);
|
||||
}
|
||||
|
||||
var supabase = Supabase.instance.client;
|
||||
await supabase.from('hosts').insert({
|
||||
'id': _gameController.uuid,
|
||||
'name': _hostingController.name.text,
|
||||
'description': _hostingController.description.text,
|
||||
'author': _gameController.username.text,
|
||||
'ip': ip,
|
||||
'version': _gameController.selectedVersion?.name,
|
||||
'password': hasPassword ? hashPassword(password) : null,
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
'discoverable': _hostingController.discoverable.value
|
||||
});
|
||||
}
|
||||
|
||||
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(cannotConnectErrors.any((element) => line.contains(element))){
|
||||
if(_fail){
|
||||
return;
|
||||
}
|
||||
|
||||
_fail = true;
|
||||
_closeLaunchingWidget(false);
|
||||
_showTokenError(host);
|
||||
return;
|
||||
}
|
||||
|
||||
if(line.contains("Region ")){
|
||||
if(!host){
|
||||
_injectOrShowError(Injectable.console, host);
|
||||
}else {
|
||||
_injectOrShowError(Injectable.reboot, host)
|
||||
.then((value) => _closeLaunchingWidget(true));
|
||||
}
|
||||
|
||||
_injectOrShowError(Injectable.memoryFix, host);
|
||||
var instance = host ? _hostingController.instance.value : _gameController.instance.value;
|
||||
instance?.tokenError = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showTokenError(bool host) async {
|
||||
var instance = host ? _hostingController.instance.value : _gameController.instance.value;
|
||||
if(_authenticatorController.type() != ServerType.embedded) {
|
||||
showTokenErrorUnfixable();
|
||||
instance?.tokenError = true;
|
||||
return;
|
||||
}
|
||||
|
||||
await _authenticatorController.restartInteractive();
|
||||
showTokenErrorFixable();
|
||||
_onStop(host);
|
||||
_start();
|
||||
}
|
||||
|
||||
void _onStop(bool host) async {
|
||||
if(_executor != null){
|
||||
await _executor;
|
||||
}
|
||||
|
||||
var instance = host ? _hostingController.instance.value : _gameController.instance.value;
|
||||
if(instance != null){
|
||||
if(instance.linkedHosting){
|
||||
_onStop(true);
|
||||
}
|
||||
|
||||
instance.kill();
|
||||
if(host){
|
||||
_hostingController.instance.value = null;
|
||||
}else {
|
||||
_gameController.instance.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
_setStarted(host, false);
|
||||
|
||||
if(host){
|
||||
var supabase = Supabase.instance.client;
|
||||
await supabase.from('hosts')
|
||||
.delete()
|
||||
.match({'id': _gameController.uuid});
|
||||
}
|
||||
|
||||
_completer = Completer();
|
||||
}
|
||||
|
||||
Future<void> _injectOrShowError(Injectable injectable, bool hosting) async {
|
||||
var instance = hosting ? _hostingController.instance.value : _gameController.instance.value;
|
||||
if (instance == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var gameProcess = instance.gamePid;
|
||||
var dllPath = await _getDllPath(injectable, hosting);
|
||||
if(dllPath == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await injectDll(gameProcess, 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((_) {
|
||||
_fail = true;
|
||||
_closeLaunchingWidget(false);
|
||||
showMissingDllError(path.basename(dllPath.path));
|
||||
_onStop(hosting);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
enum Injectable {
|
||||
console,
|
||||
sslBypass,
|
||||
reboot,
|
||||
memoryFix
|
||||
}
|
||||
338
gui/lib/src/widget/home/pane.dart
Normal file
338
gui/lib/src/widget/home/pane.dart
Normal file
@@ -0,0 +1,338 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
|
||||
class RebootPaneItem extends PaneItem {
|
||||
RebootPaneItem({required super.title, required super.icon, required super.body});
|
||||
|
||||
@override
|
||||
Widget build(
|
||||
BuildContext context,
|
||||
bool selected,
|
||||
VoidCallback? onPressed, {
|
||||
PaneDisplayMode? displayMode,
|
||||
bool showTextOnTop = true,
|
||||
int? itemIndex,
|
||||
bool? autofocus,
|
||||
}) {
|
||||
final maybeBody = _InheritedNavigationView.maybeOf(context);
|
||||
final mode = displayMode ??
|
||||
maybeBody?.displayMode ??
|
||||
maybeBody?.pane?.displayMode ??
|
||||
PaneDisplayMode.minimal;
|
||||
assert(mode != PaneDisplayMode.auto);
|
||||
assert(debugCheckHasFluentTheme(context));
|
||||
|
||||
final isTransitioning = maybeBody?.isTransitioning ?? false;
|
||||
|
||||
final theme = NavigationPaneTheme.of(context);
|
||||
final titleText = title?.getProperty<String>() ?? '';
|
||||
|
||||
final baseStyle = title?.getProperty<TextStyle>() ?? const TextStyle();
|
||||
|
||||
final isTop = mode == PaneDisplayMode.top;
|
||||
final isMinimal = mode == PaneDisplayMode.minimal;
|
||||
final isCompact = mode == PaneDisplayMode.compact;
|
||||
|
||||
final onItemTapped =
|
||||
(onPressed == null && onTap == null) || !enabled || isTransitioning
|
||||
? null
|
||||
: () {
|
||||
onPressed?.call();
|
||||
onTap?.call();
|
||||
};
|
||||
|
||||
final button = HoverButton(
|
||||
autofocus: autofocus ?? this.autofocus,
|
||||
focusNode: focusNode,
|
||||
onPressed: onItemTapped,
|
||||
cursor: mouseCursor,
|
||||
focusEnabled: isMinimal ? (maybeBody?.minimalPaneOpen ?? false) : true,
|
||||
forceEnabled: enabled,
|
||||
builder: (context, states) {
|
||||
var textStyle = () {
|
||||
var style = !isTop
|
||||
? (selected
|
||||
? theme.selectedTextStyle?.resolve(states)
|
||||
: theme.unselectedTextStyle?.resolve(states))
|
||||
: (selected
|
||||
? theme.selectedTopTextStyle?.resolve(states)
|
||||
: theme.unselectedTopTextStyle?.resolve(states));
|
||||
if (style == null) return baseStyle;
|
||||
return style.merge(baseStyle);
|
||||
}();
|
||||
|
||||
final textResult = titleText.isNotEmpty
|
||||
? Padding(
|
||||
padding: theme.labelPadding ?? EdgeInsets.zero,
|
||||
child: RichText(
|
||||
text: title!.getProperty<InlineSpan>(textStyle)!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.fade,
|
||||
softWrap: false,
|
||||
textAlign: title?.getProperty<TextAlign>() ?? TextAlign.start,
|
||||
textHeightBehavior: title?.getProperty<TextHeightBehavior>(),
|
||||
textWidthBasis: title?.getProperty<TextWidthBasis>() ??
|
||||
TextWidthBasis.parent,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
Widget result() {
|
||||
final iconThemeData = IconThemeData(
|
||||
color: textStyle.color ??
|
||||
(selected
|
||||
? theme.selectedIconColor?.resolve(states)
|
||||
: theme.unselectedIconColor?.resolve(states)),
|
||||
size: textStyle.fontSize ?? 16.0,
|
||||
);
|
||||
switch (mode) {
|
||||
case PaneDisplayMode.compact:
|
||||
return Container(
|
||||
key: itemKey,
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: kPaneItemMinHeight,
|
||||
),
|
||||
alignment: AlignmentDirectional.center,
|
||||
child: Padding(
|
||||
padding: theme.iconPadding ?? EdgeInsets.zero,
|
||||
child: IconTheme.merge(
|
||||
data: iconThemeData,
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: () {
|
||||
if (infoBadge != null) {
|
||||
return Stack(
|
||||
alignment: AlignmentDirectional.center,
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
icon,
|
||||
PositionedDirectional(
|
||||
end: -8,
|
||||
top: -8,
|
||||
child: infoBadge!,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return icon;
|
||||
}(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
case PaneDisplayMode.minimal:
|
||||
case PaneDisplayMode.open:
|
||||
final shouldShowTrailing = !isTransitioning;
|
||||
|
||||
return ConstrainedBox(
|
||||
key: itemKey,
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: kPaneItemMinHeight,
|
||||
),
|
||||
child: Row(children: [
|
||||
Padding(
|
||||
padding: theme.iconPadding ?? EdgeInsets.zero,
|
||||
child: IconTheme.merge(
|
||||
data: iconThemeData,
|
||||
child: Center(child: icon),
|
||||
),
|
||||
),
|
||||
Expanded(child: textResult),
|
||||
if (shouldShowTrailing) ...[
|
||||
if (infoBadge != null)
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 8.0),
|
||||
child: infoBadge!,
|
||||
),
|
||||
if (trailing != null)
|
||||
IconTheme.merge(
|
||||
data: const IconThemeData(size: 16.0),
|
||||
child: trailing!,
|
||||
),
|
||||
],
|
||||
]),
|
||||
);
|
||||
case PaneDisplayMode.top:
|
||||
Widget result = Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Padding(
|
||||
padding: theme.iconPadding ?? EdgeInsets.zero,
|
||||
child: IconTheme.merge(
|
||||
data: iconThemeData,
|
||||
child: Center(child: icon),
|
||||
),
|
||||
),
|
||||
if (showTextOnTop) textResult,
|
||||
if (trailing != null)
|
||||
IconTheme.merge(
|
||||
data: const IconThemeData(size: 16.0),
|
||||
child: trailing!,
|
||||
),
|
||||
]);
|
||||
if (infoBadge != null) {
|
||||
return Stack(key: itemKey, clipBehavior: Clip.none, children: [
|
||||
result,
|
||||
if (infoBadge != null)
|
||||
PositionedDirectional(
|
||||
end: -3,
|
||||
top: 3,
|
||||
child: infoBadge!,
|
||||
),
|
||||
]);
|
||||
}
|
||||
return KeyedSubtree(key: itemKey, child: result);
|
||||
default:
|
||||
throw '$mode is not a supported type';
|
||||
}
|
||||
}
|
||||
|
||||
return Semantics(
|
||||
label: titleText.isEmpty ? null : titleText,
|
||||
selected: selected,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||
decoration: BoxDecoration(
|
||||
color: () {
|
||||
final tileColor = this.tileColor ??
|
||||
theme.tileColor ??
|
||||
kDefaultPaneItemColor(context, isTop);
|
||||
final newStates = states.toSet()..remove(ButtonStates.disabled);
|
||||
if (selected && selectedTileColor != null) {
|
||||
return selectedTileColor!.resolve(newStates);
|
||||
}
|
||||
return tileColor.resolve(
|
||||
selected
|
||||
? {
|
||||
states.isHovering
|
||||
? ButtonStates.pressing
|
||||
: ButtonStates.hovering,
|
||||
}
|
||||
: newStates,
|
||||
);
|
||||
}(),
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
),
|
||||
child: FocusBorder(
|
||||
focused: states.isFocused,
|
||||
renderOutside: false,
|
||||
child: () {
|
||||
final showTooltip = ((isTop && !showTextOnTop) || isCompact) &&
|
||||
titleText.isNotEmpty &&
|
||||
!states.isDisabled;
|
||||
|
||||
if (showTooltip) {
|
||||
return Tooltip(
|
||||
richMessage: title?.getProperty<InlineSpan>(),
|
||||
style: TooltipThemeData(textStyle: baseStyle),
|
||||
child: result(),
|
||||
);
|
||||
}
|
||||
|
||||
return result();
|
||||
}(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final index = () {
|
||||
if (itemIndex != null) return itemIndex;
|
||||
if (maybeBody?.pane?.indicator != null) {
|
||||
return maybeBody!.pane!.effectiveIndexOf(this);
|
||||
}
|
||||
}();
|
||||
|
||||
return Padding(
|
||||
key: key,
|
||||
padding: const EdgeInsetsDirectional.symmetric(horizontal: 12.0, vertical: 2.0),
|
||||
child: () {
|
||||
if (maybeBody?.pane?.indicator != null &&
|
||||
index != null &&
|
||||
!index.isNegative) {
|
||||
final key = PaneItemKeys.of(index, context);
|
||||
|
||||
return Stack(children: [
|
||||
button,
|
||||
Positioned.fill(
|
||||
child: _InheritedNavigationView.merge(
|
||||
currentItemIndex: index,
|
||||
currentItemSelected: selected,
|
||||
child: KeyedSubtree(
|
||||
key: key,
|
||||
child: maybeBody!.pane!.indicator!,
|
||||
),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
return button;
|
||||
}(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InheritedNavigationView extends InheritedWidget {
|
||||
const _InheritedNavigationView({
|
||||
super.key,
|
||||
required super.child,
|
||||
required this.displayMode,
|
||||
this.minimalPaneOpen = false,
|
||||
this.pane,
|
||||
this.previousItemIndex = 0,
|
||||
this.currentItemIndex = -1,
|
||||
this.isTransitioning = false,
|
||||
});
|
||||
|
||||
final PaneDisplayMode displayMode;
|
||||
|
||||
final bool minimalPaneOpen;
|
||||
|
||||
final NavigationPane? pane;
|
||||
|
||||
final int previousItemIndex;
|
||||
|
||||
final int currentItemIndex;
|
||||
|
||||
final bool isTransitioning;
|
||||
|
||||
static _InheritedNavigationView? maybeOf(BuildContext context) {
|
||||
return context
|
||||
.dependOnInheritedWidgetOfExactType<_InheritedNavigationView>();
|
||||
}
|
||||
|
||||
static Widget merge({
|
||||
Key? key,
|
||||
required Widget child,
|
||||
int? currentItemIndex,
|
||||
NavigationPane? pane,
|
||||
PaneDisplayMode? displayMode,
|
||||
bool? minimalPaneOpen,
|
||||
int? previousItemIndex,
|
||||
bool? currentItemSelected,
|
||||
bool? isTransitioning,
|
||||
}) {
|
||||
return Builder(builder: (context) {
|
||||
final current = _InheritedNavigationView.maybeOf(context);
|
||||
return _InheritedNavigationView(
|
||||
key: key,
|
||||
displayMode:
|
||||
displayMode ?? current?.displayMode ?? PaneDisplayMode.open,
|
||||
minimalPaneOpen: minimalPaneOpen ?? current?.minimalPaneOpen ?? false,
|
||||
currentItemIndex: currentItemIndex ?? current?.currentItemIndex ?? -1,
|
||||
pane: pane ?? current?.pane,
|
||||
previousItemIndex: previousItemIndex ?? current?.previousItemIndex ?? 0,
|
||||
isTransitioning: isTransitioning ?? current?.isTransitioning ?? false,
|
||||
child: child,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(covariant _InheritedNavigationView oldWidget) {
|
||||
return oldWidget.displayMode != displayMode ||
|
||||
oldWidget.minimalPaneOpen != minimalPaneOpen ||
|
||||
oldWidget.pane != pane ||
|
||||
oldWidget.previousItemIndex != previousItemIndex ||
|
||||
oldWidget.currentItemIndex != currentItemIndex ||
|
||||
oldWidget.isTransitioning != isTransitioning;
|
||||
}
|
||||
}
|
||||
101
gui/lib/src/widget/home/profile.dart
Normal file
101
gui/lib/src/widget/home/profile.dart
Normal file
@@ -0,0 +1,101 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_common/common.dart';
|
||||
|
||||
import 'package:reboot_launcher/src/controller/game_controller.dart';
|
||||
import 'package:reboot_launcher/src/interactive/profile.dart';
|
||||
|
||||
class ProfileWidget extends StatefulWidget {
|
||||
const ProfileWidget({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ProfileWidget> createState() => _ProfileWidgetState();
|
||||
}
|
||||
|
||||
class _ProfileWidgetState extends State<ProfileWidget> {
|
||||
final GameController _gameController = Get.find<GameController>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12.0,
|
||||
horizontal: 12.0
|
||||
),
|
||||
child: Button(
|
||||
style: ButtonStyle(
|
||||
padding: ButtonState.all(EdgeInsets.zero),
|
||||
backgroundColor: ButtonState.all(Colors.transparent),
|
||||
border: ButtonState.all(const BorderSide(color: Colors.transparent))
|
||||
),
|
||||
onPressed: () async {
|
||||
if(await showProfileForm(context)) {
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle
|
||||
),
|
||||
child: Image.asset("assets/images/user.png")
|
||||
),
|
||||
const SizedBox(
|
||||
width: 12.0,
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_username,
|
||||
textAlign: TextAlign.start,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600
|
||||
),
|
||||
maxLines: 1
|
||||
),
|
||||
Text(
|
||||
_email,
|
||||
textAlign: TextAlign.start,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w100
|
||||
),
|
||||
maxLines: 1
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
String get _username {
|
||||
var username = _gameController.username.text;
|
||||
if(username.isEmpty) {
|
||||
return kDefaultPlayerName;
|
||||
}
|
||||
|
||||
var atIndex = username.indexOf("@");
|
||||
if(atIndex == -1) {
|
||||
return username.substring(0, 1).toUpperCase() + username.substring(1);
|
||||
}
|
||||
|
||||
var result = username.substring(0, atIndex);
|
||||
return result.substring(0, 1).toUpperCase() + result.substring(1);
|
||||
}
|
||||
|
||||
String get _email {
|
||||
var username = _gameController.username.text;
|
||||
if(username.isEmpty) {
|
||||
return "$kDefaultPlayerName@projectreboot.dev";
|
||||
}
|
||||
|
||||
if(username.contains("@")) {
|
||||
return username.toLowerCase();
|
||||
}
|
||||
|
||||
return "$username@projectreboot.dev".toLowerCase();
|
||||
}
|
||||
}
|
||||
27
gui/lib/src/widget/os/border.dart
Normal file
27
gui/lib/src/widget/os/border.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:reboot_common/common.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: const EdgeInsets.only(
|
||||
top: 1
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: SystemTheme.accentColor.accent,
|
||||
width: appBarWidth.toDouble()
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
193
gui/lib/src/widget/os/buttons.dart
Normal file
193
gui/lib/src/widget/os/buttons.dart
Normal file
@@ -0,0 +1,193 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import 'icons.dart';
|
||||
import 'mouse.dart';
|
||||
|
||||
typedef WindowButtonIconBuilder = Widget Function(
|
||||
WindowButtonContext buttonContext);
|
||||
typedef WindowButtonBuilder = Widget Function(
|
||||
WindowButtonContext buttonContext, Widget icon);
|
||||
|
||||
class WindowButtonContext {
|
||||
BuildContext context;
|
||||
MouseState mouseState;
|
||||
Color? backgroundColor;
|
||||
Color iconColor;
|
||||
|
||||
WindowButtonContext(
|
||||
{required this.context,
|
||||
required this.mouseState,
|
||||
this.backgroundColor,
|
||||
required this.iconColor});
|
||||
}
|
||||
|
||||
class WindowButtonColors {
|
||||
late Color normal;
|
||||
late Color mouseOver;
|
||||
late Color mouseDown;
|
||||
late Color iconNormal;
|
||||
late Color iconMouseOver;
|
||||
late Color iconMouseDown;
|
||||
|
||||
WindowButtonColors(
|
||||
{Color? normal,
|
||||
Color? mouseOver,
|
||||
Color? mouseDown,
|
||||
Color? iconNormal,
|
||||
Color? iconMouseOver,
|
||||
Color? iconMouseDown}) {
|
||||
this.normal = normal ?? _defaultButtonColors.normal;
|
||||
this.mouseOver = mouseOver ?? _defaultButtonColors.mouseOver;
|
||||
this.mouseDown = mouseDown ?? _defaultButtonColors.mouseDown;
|
||||
this.iconNormal = iconNormal ?? _defaultButtonColors.iconNormal;
|
||||
this.iconMouseOver = iconMouseOver ?? _defaultButtonColors.iconMouseOver;
|
||||
this.iconMouseDown = iconMouseDown ?? _defaultButtonColors.iconMouseDown;
|
||||
}
|
||||
}
|
||||
|
||||
final _defaultButtonColors = WindowButtonColors(
|
||||
normal: Colors.transparent,
|
||||
iconNormal: const Color(0xFF805306),
|
||||
mouseOver: const Color(0xFF404040),
|
||||
mouseDown: const Color(0xFF202020),
|
||||
iconMouseOver: const Color(0xFFFFFFFF),
|
||||
iconMouseDown: const Color(0xFFF0F0F0));
|
||||
|
||||
class WindowButton extends StatelessWidget {
|
||||
final WindowButtonBuilder? builder;
|
||||
final WindowButtonIconBuilder? iconBuilder;
|
||||
late final WindowButtonColors colors;
|
||||
final bool animate;
|
||||
final EdgeInsets? padding;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
WindowButton(
|
||||
{Key? key,
|
||||
WindowButtonColors? colors,
|
||||
this.builder,
|
||||
@required this.iconBuilder,
|
||||
this.padding,
|
||||
this.onPressed,
|
||||
this.animate = false})
|
||||
: super(key: key) {
|
||||
this.colors = colors ?? _defaultButtonColors;
|
||||
}
|
||||
|
||||
Color getBackgroundColor(MouseState mouseState) {
|
||||
if (mouseState.isMouseDown) return colors.mouseDown;
|
||||
if (mouseState.isMouseOver) return colors.mouseOver;
|
||||
return colors.normal;
|
||||
}
|
||||
|
||||
Color getIconColor(MouseState mouseState) {
|
||||
if (mouseState.isMouseDown) return colors.iconMouseDown;
|
||||
if (mouseState.isMouseOver) return colors.iconMouseOver;
|
||||
return colors.iconNormal;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseStateBuilder(
|
||||
builder: (context, mouseState) {
|
||||
WindowButtonContext buttonContext = WindowButtonContext(
|
||||
mouseState: mouseState,
|
||||
context: context,
|
||||
backgroundColor: getBackgroundColor(mouseState),
|
||||
iconColor: getIconColor(mouseState));
|
||||
|
||||
var icon =
|
||||
(iconBuilder != null) ? iconBuilder!(buttonContext) : Container();
|
||||
var fadeOutColor =
|
||||
getBackgroundColor(MouseState()..isMouseOver = true)
|
||||
.withOpacity(0);
|
||||
var padding = this.padding ?? EdgeInsets.zero;
|
||||
var animationMs = mouseState.isMouseOver
|
||||
? (animate ? 100 : 0)
|
||||
: (animate ? 200 : 0);
|
||||
Widget iconWithPadding = Padding(padding: padding, child: icon);
|
||||
iconWithPadding = AnimatedContainer(
|
||||
curve: Curves.easeOut,
|
||||
duration: Duration(milliseconds: animationMs),
|
||||
color: buttonContext.backgroundColor ?? fadeOutColor,
|
||||
child: iconWithPadding);
|
||||
var button = (builder != null)
|
||||
? builder!(buttonContext, icon)
|
||||
: iconWithPadding;
|
||||
return SizedBox.square(dimension: 45, child: button);
|
||||
},
|
||||
onPressed: onPressed);
|
||||
}
|
||||
}
|
||||
|
||||
class MinimizeWindowButton extends WindowButton {
|
||||
MinimizeWindowButton(
|
||||
{Key? key,
|
||||
WindowButtonColors? colors,
|
||||
VoidCallback? onPressed,
|
||||
bool? animate})
|
||||
: super(
|
||||
key: key,
|
||||
colors: colors,
|
||||
animate: animate ?? false,
|
||||
iconBuilder: (buttonContext) =>
|
||||
MinimizeIcon(color: buttonContext.iconColor),
|
||||
onPressed: onPressed ?? () => windowManager.minimize());
|
||||
}
|
||||
|
||||
class MaximizeWindowButton extends WindowButton {
|
||||
MaximizeWindowButton(
|
||||
{Key? key,
|
||||
WindowButtonColors? colors,
|
||||
VoidCallback? onPressed,
|
||||
bool? animate})
|
||||
: super(
|
||||
key: key,
|
||||
colors: colors,
|
||||
animate: animate ?? false,
|
||||
iconBuilder: (buttonContext) =>
|
||||
MaximizeIcon(color: buttonContext.iconColor),
|
||||
onPressed: onPressed ??
|
||||
() async => await windowManager.isMaximized()
|
||||
? await windowManager.restore()
|
||||
: await windowManager.maximize());
|
||||
}
|
||||
|
||||
class RestoreWindowButton extends WindowButton {
|
||||
RestoreWindowButton(
|
||||
{Key? key,
|
||||
WindowButtonColors? colors,
|
||||
VoidCallback? onPressed,
|
||||
bool? animate})
|
||||
: super(
|
||||
key: key,
|
||||
colors: colors,
|
||||
animate: animate ?? false,
|
||||
iconBuilder: (buttonContext) =>
|
||||
RestoreIcon(color: buttonContext.iconColor),
|
||||
onPressed: onPressed ??
|
||||
() async => await windowManager.isMaximized()
|
||||
? await windowManager.restore()
|
||||
: await windowManager.maximize());
|
||||
}
|
||||
|
||||
final _defaultCloseButtonColors = WindowButtonColors(
|
||||
mouseOver: const Color(0xFFD32F2F),
|
||||
mouseDown: const Color(0xFFB71C1C),
|
||||
iconNormal: const Color(0xFF805306),
|
||||
iconMouseOver: const Color(0xFFFFFFFF));
|
||||
|
||||
class CloseWindowButton extends WindowButton {
|
||||
CloseWindowButton(
|
||||
{Key? key,
|
||||
WindowButtonColors? colors,
|
||||
VoidCallback? onPressed,
|
||||
bool? animate})
|
||||
: super(
|
||||
key: key,
|
||||
colors: colors ?? _defaultCloseButtonColors,
|
||||
animate: animate ?? false,
|
||||
iconBuilder: (buttonContext) =>
|
||||
CloseIcon(color: buttonContext.iconColor),
|
||||
onPressed: onPressed ?? () => windowManager.close());
|
||||
}
|
||||
118
gui/lib/src/widget/os/icons.dart
Normal file
118
gui/lib/src/widget/os/icons.dart
Normal file
@@ -0,0 +1,118 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class CloseIcon extends StatelessWidget {
|
||||
final Color color;
|
||||
|
||||
const CloseIcon({Key? key, required this.color}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Stack(children: [
|
||||
Transform.rotate(
|
||||
angle: pi * .25,
|
||||
child:
|
||||
Center(child: Container(width: 14, height: 1, color: color))),
|
||||
Transform.rotate(
|
||||
angle: pi * -.25,
|
||||
child:
|
||||
Center(child: Container(width: 14, height: 1, color: color))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
class MaximizeIcon extends StatelessWidget {
|
||||
final Color color;
|
||||
|
||||
const MaximizeIcon({Key? key, required this.color}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _AlignedPaint(_MaximizePainter(color));
|
||||
}
|
||||
|
||||
class _MaximizePainter extends _IconPainter {
|
||||
_MaximizePainter(Color color) : super(color);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
Paint p = getPaint(color);
|
||||
canvas.drawRect(Rect.fromLTRB(0, 0, size.width - 1, size.height - 1), p);
|
||||
}
|
||||
}
|
||||
|
||||
class RestoreIcon extends StatelessWidget {
|
||||
final Color color;
|
||||
|
||||
const RestoreIcon({
|
||||
Key? key,
|
||||
required this.color,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _AlignedPaint(_RestorePainter(color));
|
||||
}
|
||||
|
||||
class _RestorePainter extends _IconPainter {
|
||||
_RestorePainter(Color color) : super(color);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
Paint p = getPaint(color);
|
||||
canvas.drawRect(Rect.fromLTRB(0, 2, size.width - 2, size.height), p);
|
||||
canvas.drawLine(const Offset(2, 2), const Offset(2, 0), p);
|
||||
canvas.drawLine(const Offset(2, 0), Offset(size.width, 0), p);
|
||||
canvas.drawLine(
|
||||
Offset(size.width, 0), Offset(size.width, size.height - 2), p);
|
||||
canvas.drawLine(Offset(size.width, size.height - 2),
|
||||
Offset(size.width - 2, size.height - 2), p);
|
||||
}
|
||||
}
|
||||
|
||||
class MinimizeIcon extends StatelessWidget {
|
||||
final Color color;
|
||||
|
||||
const MinimizeIcon({Key? key, required this.color}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _AlignedPaint(_MinimizePainter(color));
|
||||
}
|
||||
|
||||
class _MinimizePainter extends _IconPainter {
|
||||
_MinimizePainter(Color color) : super(color);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
Paint p = getPaint(color);
|
||||
canvas.drawLine(
|
||||
Offset(0, size.height / 2), Offset(size.width, size.height / 2), p);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _IconPainter extends CustomPainter {
|
||||
_IconPainter(this.color);
|
||||
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
class _AlignedPaint extends StatelessWidget {
|
||||
const _AlignedPaint(this.painter, {Key? key}) : super(key: key);
|
||||
final CustomPainter painter;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: Alignment.center,
|
||||
child: CustomPaint(size: const Size(10, 10), painter: painter));
|
||||
}
|
||||
}
|
||||
|
||||
Paint getPaint(Color color, [bool isAntiAlias = false]) => Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..isAntiAlias = isAntiAlias
|
||||
..strokeWidth = 1;
|
||||
76
gui/lib/src/widget/os/mouse.dart
Normal file
76
gui/lib/src/widget/os/mouse.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
typedef MouseStateBuilderCB = Widget Function(
|
||||
BuildContext context, MouseState mouseState);
|
||||
|
||||
class MouseState {
|
||||
bool isMouseOver = false;
|
||||
bool isMouseDown = false;
|
||||
|
||||
MouseState();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "isMouseDown: $isMouseDown - isMouseOver: $isMouseOver";
|
||||
}
|
||||
}
|
||||
|
||||
class MouseStateBuilder extends StatefulWidget {
|
||||
final MouseStateBuilderCB builder;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
const MouseStateBuilder({Key? key, required this.builder, this.onPressed})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
State<MouseStateBuilder> createState() => _MouseStateBuilderState();
|
||||
}
|
||||
|
||||
class _MouseStateBuilderState extends State<MouseStateBuilder> {
|
||||
late MouseState _mouseState;
|
||||
|
||||
_MouseStateBuilderState() {
|
||||
_mouseState = MouseState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (event) {
|
||||
setState(() {
|
||||
_mouseState.isMouseOver = true;
|
||||
});
|
||||
},
|
||||
onExit: (event) {
|
||||
setState(() {
|
||||
_mouseState.isMouseOver = false;
|
||||
});
|
||||
},
|
||||
child: GestureDetector(
|
||||
onTapDown: (_) {
|
||||
setState(() {
|
||||
_mouseState.isMouseDown = true;
|
||||
});
|
||||
},
|
||||
onTapCancel: () {
|
||||
setState(() {
|
||||
_mouseState.isMouseDown = false;
|
||||
});
|
||||
},
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_mouseState.isMouseDown = false;
|
||||
_mouseState.isMouseOver = false;
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (widget.onPressed != null) {
|
||||
widget.onPressed!();
|
||||
}
|
||||
});
|
||||
},
|
||||
onTapUp: (_) {},
|
||||
child: widget.builder(context, _mouseState)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
51
gui/lib/src/widget/os/title_bar.dart
Normal file
51
gui/lib/src/widget/os/title_bar.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
import 'package:reboot_launcher/src/util/os.dart';
|
||||
import 'package:reboot_launcher/src/widget/os/buttons.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;
|
||||
}
|
||||
49
gui/lib/src/widget/server/start_button.dart
Normal file
49
gui/lib/src/widget/server/start_button.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_common/common.dart';
|
||||
import 'package:reboot_launcher/src/controller/authenticator_controller.dart';
|
||||
import 'package:reboot_launcher/src/controller/matchmaker_controller.dart';
|
||||
import 'package:reboot_launcher/src/controller/server_controller.dart';
|
||||
import 'package:reboot_launcher/src/interactive/server.dart';
|
||||
|
||||
class ServerButton extends StatefulWidget {
|
||||
final bool authenticator;
|
||||
const ServerButton({Key? key, required this.authenticator}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ServerButton> createState() => _ServerButtonState();
|
||||
}
|
||||
|
||||
class _ServerButtonState extends State<ServerButton> {
|
||||
late final ServerController _controller = widget.authenticator ? Get.find<AuthenticatorController>() : Get.find<MatchmakerController>();
|
||||
|
||||
@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: () => _controller.toggleInteractive()
|
||||
),
|
||||
)),
|
||||
),
|
||||
);
|
||||
|
||||
String get _buttonText {
|
||||
if(_controller.type.value == ServerType.local){
|
||||
return "Check ${_controller.controllerName}";
|
||||
}
|
||||
|
||||
if(_controller.started.value){
|
||||
return "Stop ${_controller.controllerName}";
|
||||
}
|
||||
|
||||
return "Start ${_controller.controllerName}";
|
||||
}
|
||||
}
|
||||
57
gui/lib/src/widget/server/type_selector.dart
Normal file
57
gui/lib/src/widget/server/type_selector.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_common/src/model/server_type.dart';
|
||||
import 'package:reboot_launcher/src/controller/authenticator_controller.dart';
|
||||
import 'package:reboot_launcher/src/controller/matchmaker_controller.dart';
|
||||
import 'package:reboot_launcher/src/controller/server_controller.dart';
|
||||
|
||||
class ServerTypeSelector extends StatefulWidget {
|
||||
final bool authenticator;
|
||||
|
||||
const ServerTypeSelector({Key? key, required this.authenticator})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
State<ServerTypeSelector> createState() => _ServerTypeSelectorState();
|
||||
}
|
||||
|
||||
class _ServerTypeSelectorState extends State<ServerTypeSelector> {
|
||||
late final ServerController _controller = widget.authenticator ? Get.find<AuthenticatorController>() : Get.find<MatchmakerController>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() => DropDownButton(
|
||||
leading: Text(_controller.type.value.label),
|
||||
items: ServerType.values
|
||||
.map((type) => _createItem(type))
|
||||
.toList()
|
||||
));
|
||||
}
|
||||
|
||||
MenuFlyoutItem _createItem(ServerType type) {
|
||||
return MenuFlyoutItem(
|
||||
text: Tooltip(
|
||||
message: type.message,
|
||||
child: Text(type.label)
|
||||
),
|
||||
onPressed: () async {
|
||||
await _controller.stop();
|
||||
_controller.type.value = type;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension ServerTypeExtension on ServerType {
|
||||
String get label {
|
||||
return this == ServerType.embedded ? "Embedded"
|
||||
: this == ServerType.remote ? "Remote"
|
||||
: "Local";
|
||||
}
|
||||
|
||||
String get message {
|
||||
return this == ServerType.embedded ? "A server will be automatically started in the background"
|
||||
: this == ServerType.remote ? "A reverse proxy to the remote server will be created"
|
||||
: "Assumes that you are running yourself the server locally";
|
||||
}
|
||||
}
|
||||
101
gui/lib/src/widget/version/add_local_version.dart
Normal file
101
gui/lib/src/widget/version/add_local_version.dart
Normal file
@@ -0,0 +1,101 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_common/common.dart';
|
||||
import 'package:reboot_launcher/src/controller/game_controller.dart';
|
||||
|
||||
import 'package:reboot_launcher/src/util/checks.dart';
|
||||
import 'package:reboot_launcher/src/widget/common/file_selector.dart';
|
||||
import 'package:reboot_launcher/src/widget/version/version_name_input.dart';
|
||||
import 'package:reboot_launcher/src/dialog/dialog.dart';
|
||||
import 'package:reboot_launcher/src/dialog/dialog_button.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
class AddLocalVersion extends StatefulWidget {
|
||||
const AddLocalVersion({Key? key})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
State<AddLocalVersion> createState() => _AddLocalVersionState();
|
||||
}
|
||||
|
||||
class _AddLocalVersionState extends State<AddLocalVersion> {
|
||||
final GameController _gameController = Get.find<GameController>();
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
final TextEditingController _gamePathController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_gamePathController.addListener(() async {
|
||||
var file = Directory(_gamePathController.text);
|
||||
if(await file.exists()) {
|
||||
if(_nameController.text.isEmpty) {
|
||||
_nameController.text = path.basename(_gamePathController.text);
|
||||
}
|
||||
}
|
||||
});
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@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
|
||||
),
|
||||
|
||||
VersionNameInput(
|
||||
controller: _nameController
|
||||
),
|
||||
|
||||
const SizedBox(
|
||||
height: 16.0
|
||||
),
|
||||
|
||||
FileSelector(
|
||||
label: "Game folder",
|
||||
placeholder: "Type the game folder",
|
||||
windowTitle: "Select game folder",
|
||||
controller: _gamePathController,
|
||||
validator: checkGameFolder,
|
||||
folder: true
|
||||
),
|
||||
|
||||
const SizedBox(
|
||||
height: 16.0
|
||||
)
|
||||
],
|
||||
),
|
||||
buttons: [
|
||||
DialogButton(
|
||||
type: ButtonType.secondary
|
||||
),
|
||||
|
||||
DialogButton(
|
||||
text: "Save",
|
||||
type: ButtonType.primary,
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _gameController.addVersion(FortniteVersion(
|
||||
name: _nameController.text,
|
||||
location: Directory(_gamePathController.text)
|
||||
)));
|
||||
},
|
||||
)
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
342
gui/lib/src/widget/version/add_server_version.dart
Normal file
342
gui/lib/src/widget/version/add_server_version.dart
Normal file
@@ -0,0 +1,342 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_launcher/src/controller/game_controller.dart';
|
||||
import 'package:reboot_common/common.dart';
|
||||
import 'package:reboot_launcher/src/widget/version/version_build_selector.dart';
|
||||
import 'package:reboot_launcher/src/widget/version/version_name_input.dart';
|
||||
import 'package:universal_disk_space/universal_disk_space.dart';
|
||||
|
||||
import 'package:reboot_launcher/src/util/checks.dart';
|
||||
import 'package:reboot_launcher/src/controller/build_controller.dart';
|
||||
import 'package:reboot_launcher/src/widget/common/file_selector.dart';
|
||||
import '../../dialog/dialog.dart';
|
||||
import '../../dialog/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();
|
||||
final Rx<DownloadStatus> _status = Rx(DownloadStatus.form);
|
||||
final GlobalKey<FormState> _formKey = GlobalKey();
|
||||
final RxnInt _timeLeft = RxnInt();
|
||||
final Rxn<double> _downloadProgress = Rxn();
|
||||
|
||||
late DiskSpace _diskSpace;
|
||||
late Future _fetchFuture;
|
||||
late Future _diskFuture;
|
||||
|
||||
SendPort? _downloadPort;
|
||||
Object? _error;
|
||||
StackTrace? _stackTrace;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_fetchFuture = _buildController.builds != null
|
||||
? Future.value(true)
|
||||
: compute(fetchBuilds, null)
|
||||
.then((value) => _buildController.builds = value);
|
||||
_diskSpace = DiskSpace();
|
||||
_diskFuture = _diskSpace.scan()
|
||||
.then((_) => _updateFormDefaults());
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pathController.dispose();
|
||||
_nameController.dispose();
|
||||
_cancelDownload();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _cancelDownload() {
|
||||
Process.run('${assetsDirectory.path}\\misc\\stop.bat', []);
|
||||
_downloadPort?.send("kill");
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Form(
|
||||
key: _formKey,
|
||||
child: Obx(() {
|
||||
switch(_status.value){
|
||||
case DownloadStatus.form:
|
||||
return FutureBuilder(
|
||||
future: Future.wait([_fetchFuture, _diskFuture]),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _onDownloadError(snapshot.error, snapshot.stackTrace));
|
||||
}
|
||||
|
||||
if (!snapshot.hasData) {
|
||||
return ProgressDialog(
|
||||
text: "Fetching builds and disks...",
|
||||
onStop: () => Navigator.of(context).pop()
|
||||
);
|
||||
}
|
||||
|
||||
return FormDialog(
|
||||
content: _formBody,
|
||||
buttons: _formButtons
|
||||
);
|
||||
}
|
||||
);
|
||||
case DownloadStatus.downloading:
|
||||
return GenericDialog(
|
||||
header: _downloadBody,
|
||||
buttons: _stopButton
|
||||
);
|
||||
case DownloadStatus.extracting:
|
||||
return GenericDialog(
|
||||
header: _extractingBody,
|
||||
buttons: _stopButton
|
||||
);
|
||||
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> get _formButtons => [
|
||||
DialogButton(type: ButtonType.secondary),
|
||||
DialogButton(
|
||||
text: "Download",
|
||||
type: ButtonType.primary,
|
||||
onTap: () => _startDownload(context),
|
||||
)
|
||||
];
|
||||
|
||||
void _startDownload(BuildContext context) async {
|
||||
try {
|
||||
var build = _buildController.selectedBuild.value;
|
||||
if(build == null){
|
||||
return;
|
||||
}
|
||||
|
||||
_status.value = DownloadStatus.downloading;
|
||||
var communicationPort = ReceivePort();
|
||||
communicationPort.listen((message) {
|
||||
if(message is ArchiveDownloadProgress) {
|
||||
_onDownloadProgress(message.progress, message.minutesLeft, message.extracting);
|
||||
}else if(message is SendPort) {
|
||||
_downloadPort = message;
|
||||
}else {
|
||||
_onDownloadError("Unexpected message: $message", null);
|
||||
}
|
||||
});
|
||||
var options = ArchiveDownloadOptions(
|
||||
build.link,
|
||||
Directory(_pathController.text),
|
||||
communicationPort.sendPort
|
||||
);
|
||||
var errorPort = ReceivePort();
|
||||
errorPort.listen((message) => _onDownloadError(message, null));
|
||||
var exitPort = ReceivePort();
|
||||
exitPort.listen((message) {
|
||||
if(_status.value != DownloadStatus.error) {
|
||||
_onDownloadComplete();
|
||||
}
|
||||
});
|
||||
await Isolate.spawn(
|
||||
downloadArchiveBuild,
|
||||
options,
|
||||
onError: errorPort.sendPort,
|
||||
onExit: exitPort.sendPort,
|
||||
errorsAreFatal: true
|
||||
);
|
||||
} catch (exception, stackTrace) {
|
||||
_onDownloadError(exception, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onDownloadComplete() async {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
_status.value = DownloadStatus.done;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _gameController.addVersion(FortniteVersion(
|
||||
name: _nameController.text,
|
||||
location: Directory(_pathController.text)
|
||||
)));
|
||||
}
|
||||
|
||||
void _onDownloadError(Object? error, StackTrace? stackTrace) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
_status.value = DownloadStatus.error;
|
||||
_error = error;
|
||||
_stackTrace = stackTrace;
|
||||
}
|
||||
|
||||
void _onDownloadProgress(double progress, int timeLeft, bool extracting) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
_status.value = extracting ? DownloadStatus.extracting : DownloadStatus.downloading;
|
||||
_timeLeft.value = timeLeft;
|
||||
_downloadProgress.value = progress;
|
||||
}
|
||||
|
||||
Widget get _downloadBody {
|
||||
var timeLeft = _timeLeft.value;
|
||||
return 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.value ?? 0).round()}%",
|
||||
style: FluentTheme.maybeOf(context)?.typography.body,
|
||||
),
|
||||
|
||||
if(timeLeft != null)
|
||||
Text(
|
||||
"Time left: ${timeLeft == 0 ? "less than a minute" : "about $timeLeft minute${timeLeft > 1 ? 's' : ''}"}",
|
||||
style: FluentTheme.maybeOf(context)?.typography.body,
|
||||
)
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ProgressBar(value: (_downloadProgress.value ?? 0).toDouble())
|
||||
),
|
||||
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget get _extractingBody => 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 get _formBody => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
BuildSelector(
|
||||
onSelected: _updateFormDefaults
|
||||
),
|
||||
|
||||
const SizedBox(
|
||||
height: 16.0
|
||||
),
|
||||
|
||||
VersionNameInput(
|
||||
controller: _nameController
|
||||
),
|
||||
|
||||
const SizedBox(
|
||||
height: 16.0
|
||||
),
|
||||
|
||||
FileSelector(
|
||||
label: "Installation directory",
|
||||
placeholder: "Type the installation directory",
|
||||
windowTitle: "Select installation directory",
|
||||
controller: _pathController,
|
||||
validator: checkDownloadDestination,
|
||||
folder: true
|
||||
),
|
||||
|
||||
const SizedBox(
|
||||
height: 16.0
|
||||
)
|
||||
],
|
||||
);
|
||||
|
||||
List<DialogButton> get _stopButton => [
|
||||
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);
|
||||
var build = _buildController.selectedBuild.value;
|
||||
if(build== null){
|
||||
return;
|
||||
}
|
||||
|
||||
_pathController.text = "${bestDisk.devicePath}\\FortniteBuilds\\${build.version}";
|
||||
_nameController.text = build.version.toString();
|
||||
_formKey.currentState?.validate();
|
||||
}
|
||||
}
|
||||
|
||||
enum DownloadStatus { form, downloading, extracting, error, done }
|
||||
51
gui/lib/src/widget/version/version_build_selector.dart
Normal file
51
gui/lib/src/widget/version/version_build_selector.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_common/common.dart';
|
||||
import 'package:reboot_launcher/src/controller/build_controller.dart';
|
||||
|
||||
class BuildSelector extends StatefulWidget {
|
||||
final Function() onSelected;
|
||||
|
||||
const BuildSelector({Key? key, required this.onSelected}) : 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: Obx(() => ComboBox<FortniteBuild>(
|
||||
placeholder: const Text('Select a fortnite build'),
|
||||
isExpanded: true,
|
||||
items: _createItems(),
|
||||
value: _buildController.selectedBuild.value,
|
||||
onChanged: (value) {
|
||||
if(value == null){
|
||||
return;
|
||||
}
|
||||
|
||||
_buildController.selectedBuild.value = value;
|
||||
widget.onSelected();
|
||||
}
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
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())
|
||||
);
|
||||
}
|
||||
}
|
||||
34
gui/lib/src/widget/version/version_name_input.dart
Normal file
34
gui/lib/src/widget/version/version_name_input.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_launcher/src/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) => InfoLabel(
|
||||
label: "Name",
|
||||
child: TextFormBox(
|
||||
controller: controller,
|
||||
placeholder: "Type the version's name",
|
||||
autofocus: true,
|
||||
validator: _validate,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction
|
||||
),
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
258
gui/lib/src/widget/version/version_selector.dart
Normal file
258
gui/lib/src/widget/version/version_selector.dart
Normal file
@@ -0,0 +1,258 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fluent_ui/fluent_ui.dart' hide showDialog;
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:reboot_common/common.dart';
|
||||
import 'package:reboot_launcher/src/controller/game_controller.dart';
|
||||
import 'package:reboot_launcher/src/widget/version/add_local_version.dart';
|
||||
import 'package:reboot_launcher/src/widget/version/add_server_version.dart';
|
||||
import 'package:reboot_launcher/src/dialog/dialog.dart';
|
||||
import 'package:reboot_launcher/src/dialog/dialog_button.dart';
|
||||
import 'package:reboot_launcher/src/dialog/message.dart';
|
||||
import 'package:reboot_launcher/src/util/checks.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'package:reboot_launcher/src/widget/common/file_selector.dart';
|
||||
|
||||
class VersionSelector extends StatefulWidget {
|
||||
const VersionSelector({Key? key}) : super(key: key);
|
||||
|
||||
static Future<void> openDownloadDialog() => showDialog<bool>(
|
||||
builder: (context) => const AddServerVersion(),
|
||||
);
|
||||
|
||||
static Future<void> openAddDialog() => showDialog<bool>(
|
||||
builder: (context) => const AddLocalVersion(),
|
||||
);
|
||||
|
||||
@override
|
||||
State<VersionSelector> createState() => _VersionSelectorState();
|
||||
}
|
||||
|
||||
class _VersionSelectorState extends State<VersionSelector> {
|
||||
final GameController _gameController = Get.find<GameController>();
|
||||
final RxBool _deleteFilesController = RxBool(false);
|
||||
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 (_deleteFilesController.value && await version.location.exists()) {
|
||||
delete(version.location);
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
MenuFlyoutItem _createOption(BuildContext context, ContextualOption entry) {
|
||||
return MenuFlyoutItem(
|
||||
text: Text(entry.name),
|
||||
onPressed: () => Navigator.of(context).pop(entry)
|
||||
);
|
||||
}
|
||||
|
||||
bool _onExplorerError() {
|
||||
showMessage("This version doesn't exist on the local machine");
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool?> _openDeleteDialog(BuildContext context, FortniteVersion version) {
|
||||
return showDialog<bool>(
|
||||
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),
|
||||
|
||||
Obx(() => Checkbox(
|
||||
checked: _deleteFilesController.value,
|
||||
onChanged: (bool? value) => _deleteFilesController.value = value ?? false,
|
||||
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?>(
|
||||
builder: (context) => FormDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InfoLabel(
|
||||
label: "Name",
|
||||
child: TextFormBox(
|
||||
controller: nameController,
|
||||
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",
|
||||
label: "Path",
|
||||
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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user