diff --git a/launcher/pom.xml b/launcher/pom.xml index 38b3094..00d1d5a 100644 --- a/launcher/pom.xml +++ b/launcher/pom.xml @@ -26,6 +26,12 @@ javafx-fxml ${javafx.version} + + + com.google.code.gson + gson + 2.9.1 + diff --git a/launcher/src/main/java/module-info.java b/launcher/src/main/java/module-info.java index 5edf66c..071f7a0 100644 --- a/launcher/src/main/java/module-info.java +++ b/launcher/src/main/java/module-info.java @@ -4,6 +4,10 @@ module aos2_launcher { requires javafx.graphics; requires javafx.fxml; + requires java.net.http; + requires com.google.gson; + exports nl.andrewl.aos2_launcher to javafx.graphics; opens nl.andrewl.aos2_launcher to javafx.fxml; + opens nl.andrewl.aos2_launcher.view to javafx.fxml; } \ No newline at end of file diff --git a/launcher/src/main/java/nl/andrewl/aos2_launcher/EditProfileController.java b/launcher/src/main/java/nl/andrewl/aos2_launcher/EditProfileController.java new file mode 100644 index 0000000..7197aa3 --- /dev/null +++ b/launcher/src/main/java/nl/andrewl/aos2_launcher/EditProfileController.java @@ -0,0 +1,4 @@ +package nl.andrewl.aos2_launcher; + +public class EditProfileController { +} diff --git a/launcher/src/main/java/nl/andrewl/aos2_launcher/Launcher.java b/launcher/src/main/java/nl/andrewl/aos2_launcher/Launcher.java index bce8874..78330ff 100644 --- a/launcher/src/main/java/nl/andrewl/aos2_launcher/Launcher.java +++ b/launcher/src/main/java/nl/andrewl/aos2_launcher/Launcher.java @@ -7,22 +7,41 @@ import javafx.scene.Scene; import javafx.stage.Stage; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; /** * The main starting point for the launcher app. */ public class Launcher extends Application { + public static final Path BASE_DIR = Path.of(System.getProperty("user.home"), ".ace-of-shades"); + public static final Path VERSIONS_DIR = BASE_DIR.resolve("versions"); + public static final Path PROFILES_FILE = BASE_DIR.resolve("profiles.json"); + @Override public void start(Stage stage) throws IOException { + if (!Files.exists(BASE_DIR)) { + Files.createDirectory(BASE_DIR); + } + if (!Files.exists(VERSIONS_DIR)) { + Files.createDirectory(VERSIONS_DIR); + } FXMLLoader loader = new FXMLLoader(Launcher.class.getResource("/main_view.fxml")); Parent rootNode = loader.load(); Scene scene = new Scene(rootNode); - scene.getStylesheets().add(Launcher.class.getResource("/styles.css").toExternalForm()); + addStylesheet(scene, "/font/fonts.css"); + addStylesheet(scene, "/styles.css"); stage.setScene(scene); stage.setTitle("Ace of Shades 2 - Launcher"); stage.show(); } + private void addStylesheet(Scene scene, String resource) throws IOException { + var url = Launcher.class.getResource(resource); + if (url == null) throw new IOException("Could not load resource at " + resource); + scene.getStylesheets().add(url.toExternalForm()); + } + public static void main(String[] args) { launch(args); } diff --git a/launcher/src/main/java/nl/andrewl/aos2_launcher/MainViewController.java b/launcher/src/main/java/nl/andrewl/aos2_launcher/MainViewController.java index 92e8338..beea6ae 100644 --- a/launcher/src/main/java/nl/andrewl/aos2_launcher/MainViewController.java +++ b/launcher/src/main/java/nl/andrewl/aos2_launcher/MainViewController.java @@ -1,16 +1,216 @@ package nl.andrewl.aos2_launcher; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; import javafx.application.Platform; -import javafx.event.ActionEvent; +import javafx.beans.binding.BooleanBinding; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.css.PseudoClass; import javafx.fxml.FXML; -import javafx.scene.layout.TilePane; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.VBox; +import nl.andrewl.aos2_launcher.model.Profile; +import nl.andrewl.aos2_launcher.model.Server; +import nl.andrewl.aos2_launcher.view.BindingUtil; +import nl.andrewl.aos2_launcher.view.EditProfileDialog; +import nl.andrewl.aos2_launcher.view.ProfileView; +import nl.andrewl.aos2_launcher.view.ServerView; + +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; public class MainViewController { @FXML - public TilePane profilesTilePane; + public Button playButton; + @FXML + public Button editProfileButton; + @FXML + public Button removeProfileButton; + @FXML + public VBox profilesVBox; + @FXML + public VBox serversVBox; + @FXML + public Label selectedProfileLabel; + @FXML + public Label selectedServerLabel; + private final ObservableList profiles = FXCollections.observableArrayList(); + private final ObservableList servers = FXCollections.observableArrayList(); + private final ObjectProperty selectedServer = new SimpleObjectProperty<>(null); + private final ObjectProperty selectedProfile = new SimpleObjectProperty<>(null); - public void onExit(ActionEvent actionEvent) { - Platform.exit(); + private final ServersFetcher serversFetcher = new ServersFetcher(); + + @FXML + public void initialize() { + BindingUtil.mapContent(serversVBox.getChildren(), servers, ServerView::new); + BindingUtil.mapContent(profilesVBox.getChildren(), profiles, ProfileView::new); + selectedProfile.addListener((observable, oldValue, newValue) -> { + if (newValue == null) { + selectedProfileLabel.setText("None"); + } else { + selectedProfileLabel.setText(newValue.getName()); + } + }); + selectedServer.addListener((observable, oldValue, newValue) -> { + if (newValue == null) { + selectedServerLabel.setText("None"); + } else { + selectedServerLabel.setText(newValue.getName()); + } + }); + BooleanBinding playBind = selectedProfile.isNull().or(selectedServer.isNull()); + playButton.disableProperty().bind(playBind); + editProfileButton.disableProperty().bind(selectedProfile.isNull()); + removeProfileButton.disableProperty().bind(selectedProfile.isNull()); + + profilesVBox.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> { + Node target = (Node) event.getTarget(); + while (target != null) { + if (target instanceof ProfileView view) { + if (view.getProfile().equals(selectedProfile.get()) && event.isControlDown()) { + selectProfile(null); + } else if (!event.isControlDown() && event.getClickCount() == 2) { + selectProfile(view); + editProfile(); + } else { + selectProfile(view); + } + return; + } + target = target.getParent(); + } + selectProfile(null); + }); + serversVBox.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> { + Node target = (Node) event.getTarget(); + while (target != null) { + if (target instanceof ServerView view) { + if (view.getServer().equals(selectedServer.get()) && event.isControlDown()) { + selectServer(null); + } else { + selectServer(view); + } + return; + } + target = target.getParent(); + } + selectServer(null); + }); + loadProfiles(); + refreshServers(); + } + + @FXML + public void refreshServers() { + serversFetcher.fetchServers() + .exceptionally(throwable -> { + throwable.printStackTrace(); + return new ArrayList<>(); + }) + .thenAccept(newServers -> { + Platform.runLater(() -> { + this.servers.clear(); + this.servers.addAll(newServers); + }); + }); + } + + @FXML + public void addProfile() { + EditProfileDialog dialog = new EditProfileDialog(profilesVBox.getScene().getWindow()); + dialog.showAndWait().ifPresent(profiles::add); + saveProfiles(); + } + + @FXML + public void editProfile() { + EditProfileDialog dialog = new EditProfileDialog(profilesVBox.getScene().getWindow(), selectedProfile.get()); + dialog.showAndWait(); + saveProfiles(); + } + + @FXML + public void removeProfile() { + if (selectedProfile.getValue() != null) { + profiles.remove(selectedProfile.getValue()); + saveProfiles(); + } + } + + @FXML + public void play() { + + } + + private void selectProfile(ProfileView view) { + Profile profile = view == null ? null : view.getProfile(); + selectedProfile.set(profile); + PseudoClass selectedClass = PseudoClass.getPseudoClass("selected"); + for (var node : profilesVBox.getChildren()) { + node.pseudoClassStateChanged(selectedClass, false); + } + if (view != null) { + view.pseudoClassStateChanged(selectedClass, true); + } + } + + private void selectServer(ServerView view) { + Server server = view == null ? null : view.getServer(); + selectedServer.set(server); + PseudoClass selectedClass = PseudoClass.getPseudoClass("selected"); + for (var node : serversVBox.getChildren()) { + node.pseudoClassStateChanged(selectedClass, false); + } + if (view != null) { + view.pseudoClassStateChanged(selectedClass, true); + } + } + + private void loadProfiles() { + if (!Files.exists(Launcher.PROFILES_FILE)) return; + try (var reader = Files.newBufferedReader(Launcher.PROFILES_FILE)) { + profiles.clear(); + JsonArray array = new Gson().fromJson(reader, JsonArray.class); + for (var element : array) { + if (element.isJsonObject()) { + JsonObject obj = element.getAsJsonObject(); + Profile profile = new Profile(); + profile.setName(obj.get("name").getAsString()); + profile.setDescription(obj.get("description").getAsString()); + profile.setClientVersion(obj.get("clientVersion").getAsString()); + profiles.add(profile); + } + } + } catch (IOException e) { + e.printStackTrace(); + } + } + + private void saveProfiles() { + JsonArray array = new JsonArray(profiles.size()); + for (var profile : profiles) { + JsonObject obj = new JsonObject(); + obj.addProperty("name", profile.getName()); + obj.addProperty("description", profile.getDescription()); + obj.addProperty("clientVersion", profile.getClientVersion()); + array.add(obj); + } + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + try (var writer = Files.newBufferedWriter(Launcher.PROFILES_FILE)) { + gson.toJson(array, writer); + } catch (IOException e) { + e.printStackTrace(); + } } } diff --git a/launcher/src/main/java/nl/andrewl/aos2_launcher/ServersFetcher.java b/launcher/src/main/java/nl/andrewl/aos2_launcher/ServersFetcher.java new file mode 100644 index 0000000..b32e641 --- /dev/null +++ b/launcher/src/main/java/nl/andrewl/aos2_launcher/ServersFetcher.java @@ -0,0 +1,59 @@ +package nl.andrewl.aos2_launcher; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import nl.andrewl.aos2_launcher.model.Server; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +class ServersFetcher { + private static final String registryURL = "http://localhost:8080"; + + private final HttpClient httpClient; + private final Gson gson; + + public ServersFetcher() { + httpClient = HttpClient.newBuilder().build(); + gson = new Gson(); + } + + public CompletableFuture> fetchServers() { + HttpRequest req = HttpRequest.newBuilder(URI.create(registryURL + "/servers")) + .GET() + .timeout(Duration.ofSeconds(3)) + .header("Accept", "application/json") + .build(); + return httpClient.sendAsync(req, HttpResponse.BodyHandlers.ofString()) + .thenApplyAsync(resp -> { + if (resp.statusCode() == 200) { + JsonArray serversArray = gson.fromJson(resp.body(), JsonArray.class); + List servers = new ArrayList<>(serversArray.size()); + for (JsonElement serverJson : serversArray) { + if (serverJson instanceof JsonObject obj) { + servers.add(new Server( + obj.get("host").getAsString(), + obj.get("port").getAsInt(), + obj.get("name").getAsString(), + obj.get("description").getAsString(), + obj.get("maxPlayers").getAsInt(), + obj.get("currentPlayers").getAsInt(), + obj.get("lastUpdatedAt").getAsLong() + )); + } + } + return servers; + } else { + throw new RuntimeException("Invalid response: " + resp.statusCode()); + } + }); + } +} diff --git a/launcher/src/main/java/nl/andrewl/aos2_launcher/model/Profile.java b/launcher/src/main/java/nl/andrewl/aos2_launcher/model/Profile.java new file mode 100644 index 0000000..51e65a2 --- /dev/null +++ b/launcher/src/main/java/nl/andrewl/aos2_launcher/model/Profile.java @@ -0,0 +1,52 @@ +package nl.andrewl.aos2_launcher.model; + +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; + +public class Profile { + private final StringProperty name; + private final StringProperty description; + private final StringProperty clientVersion; + + public Profile() { + this.name = new SimpleStringProperty(""); + this.description = new SimpleStringProperty(null); + this.clientVersion = new SimpleStringProperty(null); + } + + public String getName() { + return name.get(); + } + + public StringProperty nameProperty() { + return name; + } + + public String getDescription() { + return description.get(); + } + + public StringProperty descriptionProperty() { + return description; + } + + public String getClientVersion() { + return clientVersion.get(); + } + + public StringProperty clientVersionProperty() { + return clientVersion; + } + + public void setName(String name) { + this.name.set(name); + } + + public void setDescription(String description) { + this.description.set(description); + } + + public void setClientVersion(String clientVersion) { + this.clientVersion.set(clientVersion); + } +} diff --git a/launcher/src/main/java/nl/andrewl/aos2_launcher/model/Server.java b/launcher/src/main/java/nl/andrewl/aos2_launcher/model/Server.java new file mode 100644 index 0000000..437f7b5 --- /dev/null +++ b/launcher/src/main/java/nl/andrewl/aos2_launcher/model/Server.java @@ -0,0 +1,84 @@ +package nl.andrewl.aos2_launcher.model; + +import javafx.beans.property.*; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; + +public class Server { + private final StringProperty host; + private final IntegerProperty port; + private final StringProperty name; + private final StringProperty description; + private final IntegerProperty maxPlayers; + private final IntegerProperty currentPlayers; + private final ObjectProperty lastUpdatedAt; + + public Server(String host, int port, String name, String description, int maxPlayers, int currentPlayers, long lastUpdatedAt) { + this.host = new SimpleStringProperty(host); + this.port = new SimpleIntegerProperty(port); + this.name = new SimpleStringProperty(name); + this.description = new SimpleStringProperty(description); + this.maxPlayers = new SimpleIntegerProperty(maxPlayers); + this.currentPlayers = new SimpleIntegerProperty(currentPlayers); + LocalDateTime ts = Instant.ofEpochMilli(lastUpdatedAt).atZone(ZoneId.systemDefault()).toLocalDateTime(); + this.lastUpdatedAt = new SimpleObjectProperty<>(ts); + } + + public String getHost() { + return host.get(); + } + + public StringProperty hostProperty() { + return host; + } + + public int getPort() { + return port.get(); + } + + public IntegerProperty portProperty() { + return port; + } + + public String getName() { + return name.get(); + } + + public StringProperty nameProperty() { + return name; + } + + public String getDescription() { + return description.get(); + } + + public StringProperty descriptionProperty() { + return description; + } + + public int getMaxPlayers() { + return maxPlayers.get(); + } + + public IntegerProperty maxPlayersProperty() { + return maxPlayers; + } + + public int getCurrentPlayers() { + return currentPlayers.get(); + } + + public IntegerProperty currentPlayersProperty() { + return currentPlayers; + } + + public LocalDateTime getLastUpdatedAt() { + return lastUpdatedAt.get(); + } + + public Property lastUpdatedAtProperty() { + return lastUpdatedAt; + } +} diff --git a/launcher/src/main/java/nl/andrewl/aos2_launcher/view/BindingUtil.java b/launcher/src/main/java/nl/andrewl/aos2_launcher/view/BindingUtil.java new file mode 100644 index 0000000..30055c2 --- /dev/null +++ b/launcher/src/main/java/nl/andrewl/aos2_launcher/view/BindingUtil.java @@ -0,0 +1,90 @@ +package nl.andrewl.aos2_launcher.view; + +import javafx.beans.WeakListener; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; + +import java.lang.ref.WeakReference; +import java.util.List; +import java.util.function.Function; + +import static java.util.stream.Collectors.toList; + +public class BindingUtil { + public static void mapContent(ObservableList mapped, ObservableList source, + Function mapper) { + map(mapped, source, mapper); + } + + private static Object map(ObservableList mapped, ObservableList source, + Function mapper) { + final ListContentMapping contentMapping = new ListContentMapping<>(mapped, mapper); + mapped.setAll(source.stream().map(mapper).collect(toList())); + source.removeListener(contentMapping); + source.addListener(contentMapping); + return contentMapping; + } + + private static class ListContentMapping implements ListChangeListener, WeakListener { + private final WeakReference> mappedRef; + private final Function mapper; + + public ListContentMapping(List mapped, Function mapper) { + this.mappedRef = new WeakReference<>(mapped); + this.mapper = mapper; + } + + @Override + public void onChanged(Change change) { + final List mapped = mappedRef.get(); + if (mapped == null) { + change.getList().removeListener(this); + } else { + while (change.next()) { + if (change.wasPermutated()) { + mapped.subList(change.getFrom(), change.getTo()).clear(); + mapped.addAll(change.getFrom(), change.getList().subList(change.getFrom(), change.getTo()) + .stream().map(mapper).toList()); + } else { + if (change.wasRemoved()) { + mapped.subList(change.getFrom(), change.getFrom() + change.getRemovedSize()).clear(); + } + if (change.wasAdded()) { + mapped.addAll(change.getFrom(), change.getAddedSubList() + .stream().map(mapper).toList()); + } + } + } + } + } + + @Override + public boolean wasGarbageCollected() { + return mappedRef.get() == null; + } + + @Override + public int hashCode() { + final List list = mappedRef.get(); + return (list == null) ? 0 : list.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + + final List mapped1 = mappedRef.get(); + if (mapped1 == null) { + return false; + } + + if (obj instanceof final ListContentMapping other) { + final List mapped2 = other.mappedRef.get(); + return mapped1 == mapped2; + } + return false; + } + } +} diff --git a/launcher/src/main/java/nl/andrewl/aos2_launcher/view/EditProfileDialog.java b/launcher/src/main/java/nl/andrewl/aos2_launcher/view/EditProfileDialog.java new file mode 100644 index 0000000..7f9da6d --- /dev/null +++ b/launcher/src/main/java/nl/andrewl/aos2_launcher/view/EditProfileDialog.java @@ -0,0 +1,79 @@ +package nl.andrewl.aos2_launcher.view; + +import javafx.application.Platform; +import javafx.beans.binding.BooleanBinding; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.FXCollections; +import javafx.fxml.FXML; +import javafx.fxml.FXMLLoader; +import javafx.scene.Parent; +import javafx.scene.control.*; +import javafx.stage.Modality; +import javafx.stage.Window; +import nl.andrewl.aos2_launcher.model.Profile; + +import java.io.IOException; +import java.util.Objects; + +public class EditProfileDialog extends Dialog { + @FXML + public TextField nameField; + @FXML + public TextArea descriptionTextArea; + @FXML + public ChoiceBox clientVersionChoiceBox; + + private final ObjectProperty profile; + + public EditProfileDialog(Window owner, Profile profile) { + this.profile = new SimpleObjectProperty<>(profile); + try { + FXMLLoader loader = new FXMLLoader(EditProfileDialog.class.getResource("/dialog/edit_profile.fxml")); + loader.setController(this); + Parent parent = loader.load(); + initOwner(owner); + initModality(Modality.APPLICATION_MODAL); + setResizable(true); + setTitle("Edit Profile"); + + BooleanBinding formInvalid = nameField.textProperty().isEmpty() + .or(clientVersionChoiceBox.valueProperty().isNull()); + nameField.setText(profile.getName()); + descriptionTextArea.setText(profile.getDescription()); + clientVersionChoiceBox.setItems(FXCollections.observableArrayList("v1.2.0", "v1.3.0", "v1.4.0")); + clientVersionChoiceBox.setValue(profile.getClientVersion()); + + DialogPane pane = new DialogPane(); + pane.setContent(parent); + ButtonType okButton = new ButtonType("Ok", ButtonBar.ButtonData.OK_DONE); + ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE); + pane.getButtonTypes().add(okButton); + pane.getButtonTypes().add(cancelButton); + pane.lookupButton(okButton).disableProperty().bind(formInvalid); + setDialogPane(pane); + setResultConverter(buttonType -> { + if (!Objects.equals(ButtonBar.ButtonData.OK_DONE, buttonType.getButtonData())) { + return null; + } + var prof = this.profile.getValue(); + prof.setName(nameField.getText().trim()); + String descriptionText = descriptionTextArea.getText().trim(); + if (descriptionText.isBlank()) { + prof.setDescription(null); + } else { + prof.setDescription(descriptionText); + } + prof.setClientVersion(clientVersionChoiceBox.getValue()); + return this.profile.getValue(); + }); + setOnShowing(event -> Platform.runLater(() -> nameField.requestFocus())); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public EditProfileDialog(Window owner) { + this(owner, new Profile()); + } +} diff --git a/launcher/src/main/java/nl/andrewl/aos2_launcher/view/ProfileView.java b/launcher/src/main/java/nl/andrewl/aos2_launcher/view/ProfileView.java new file mode 100644 index 0000000..7d0a2bd --- /dev/null +++ b/launcher/src/main/java/nl/andrewl/aos2_launcher/view/ProfileView.java @@ -0,0 +1,25 @@ +package nl.andrewl.aos2_launcher.view; + +import javafx.scene.control.Label; +import javafx.scene.layout.VBox; +import nl.andrewl.aos2_launcher.model.Profile; + +public class ProfileView extends VBox { + private final Profile profile; + + public ProfileView(Profile profile) { + this.profile = profile; + var nameLabel = new Label(); + nameLabel.textProperty().bind(profile.nameProperty()); + var descriptionLabel = new Label(); + descriptionLabel.textProperty().bind(profile.descriptionProperty()); + var versionLabel = new Label(); + versionLabel.textProperty().bind(profile.clientVersionProperty()); + getChildren().addAll(nameLabel, descriptionLabel, versionLabel); + getStyleClass().add("list-item"); + } + + public Profile getProfile() { + return this.profile; + } +} diff --git a/launcher/src/main/java/nl/andrewl/aos2_launcher/view/ServerView.java b/launcher/src/main/java/nl/andrewl/aos2_launcher/view/ServerView.java new file mode 100644 index 0000000..5ec8bad --- /dev/null +++ b/launcher/src/main/java/nl/andrewl/aos2_launcher/view/ServerView.java @@ -0,0 +1,33 @@ +package nl.andrewl.aos2_launcher.view; + +import javafx.scene.control.Label; +import javafx.scene.layout.VBox; +import nl.andrewl.aos2_launcher.model.Server; + +public class ServerView extends VBox { + private final Server server; + + public ServerView(Server server) { + this.server = server; + var hostLabel = new Label(); + hostLabel.textProperty().bind(server.hostProperty()); + var portLabel = new Label(); + portLabel.setText(Integer.toString(server.getPort())); + server.portProperty().addListener((observableValue, x1, x2) -> { + portLabel.setText(x2.toString()); + }); + var nameLabel = new Label(); + nameLabel.textProperty().bind(server.nameProperty()); + var descriptionLabel = new Label(); + descriptionLabel.textProperty().bind(server.descriptionProperty()); + var playersLabel = new Label(); + + var nodes = getChildren(); + nodes.addAll(hostLabel, portLabel, nameLabel, descriptionLabel); + getStyleClass().add("list-item"); + } + + public Server getServer() { + return server; + } +} diff --git a/launcher/src/main/resources/dialog/edit_profile.fxml b/launcher/src/main/resources/dialog/edit_profile.fxml new file mode 100644 index 0000000..7c28f79 --- /dev/null +++ b/launcher/src/main/resources/dialog/edit_profile.fxml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + +