Delete / leave / Archive in group

This commit is contained in:
2025-09-08 08:23:04 +03:30
parent 774a570d99
commit 98a5d85f55
9 changed files with 1270 additions and 1394 deletions
+1
View File
@@ -15,6 +15,7 @@ module org.to.telegramfinalproject {
requires spark.core;
requires javax.servlet.api;
requires mp3agic;
requires jdk.internal.le;
// FXML کنترلرها در این پکیج‌اند:
opens org.to.telegramfinalproject.UI to javafx.fxml;
File diff suppressed because it is too large Load Diff
@@ -289,10 +289,45 @@ public class GroupInfoController {
}
private void handleDeleteGroup() {
System.out.println("Deleting group...");
// TODO: implement backend call
ChatEntry entry = Session.currentChatEntry;
// if (entry == null || !"group".equalsIgnoreCase(entry.getType())) {
// alert(Alert.AlertType.INFORMATION, "This action is only available for groups.");
// return;
// }
if (!confirm("Delete Group",
"Are you sure you want to DELETE this group?\nThis action cannot be undone.")) {
return;
}
JSONObject req = new JSONObject()
.put("action", "delete_group")
.put("group_id", entry.getId().toString());
JSONObject res = ActionHandler.sendWithResponse(req);
if (res != null && "success".equalsIgnoreCase(res.optString("status"))) {
alert(Alert.AlertType.INFORMATION, "✅ Group deleted successfully.");
MainController.getInstance().refreshChatListUI();
AppRouter.showMain();
} else {
String msg = (res != null) ? res.optString("message", "Failed to delete group.") : "null response";
alert(Alert.AlertType.ERROR, "" + msg);
}
}
private boolean confirm(String title, String msg) {
Alert a = new Alert(Alert.AlertType.CONFIRMATION, msg, ButtonType.OK, ButtonType.CANCEL);
a.setTitle(title);
return a.showAndWait().filter(btn -> btn == ButtonType.OK).isPresent();
}
private void alert(Alert.AlertType type, String msg) {
new Alert(type, msg, ButtonType.OK).show();
}
private void updateIcons(boolean dark) {
String suffix = dark ? "_light.png" : "_dark.png";
@@ -0,0 +1,359 @@
package org.to.telegramfinalproject.UI;
import javafx.application.Platform;
import javafx.concurrent.Task;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.*;
import org.json.JSONArray;
import org.json.JSONObject;
import org.to.telegramfinalproject.Client.ActionHandler;
import org.to.telegramfinalproject.Client.Session;
import org.to.telegramfinalproject.Models.ChatEntry;
import java.util.*;
import java.util.stream.Collectors;
import java.util.UUID;
public final class LeaveGroupFlow {
private LeaveGroupFlow() {}
// ==== Public API: call this from your controller when menu item clicked
public static void start(ChatEntry entry) {
if (entry == null || !"group".equalsIgnoreCase(entry.getType())) {
info("This action is only available for groups.");
return;
}
final String myUuidStr = Session.getUserUUID();
if (myUuidStr == null || myUuidStr.isBlank()) {
error("Cannot determine your identity (internal UUID missing).");
return;
}
if (!entry.isOwner()) {
// Non-owner: confirm and leave directly
if (!confirm("Leave Group", "Are you sure you want to leave this group?")) return;
leaveGroupAsync(entry.getId(), myUuidStr, () -> info("You left the group."));
return;
}
// Owner: choose new owner (server returns list WITHOUT self)
openOwnerPickerAndLeave(entry.getId(), myUuidStr);
}
// ==== Owner flow ====
private static void openOwnerPickerAndLeave(UUID groupId, String myUuidStr) {
ProgressIndicator pi = showLoading("Loading admins...");
Task<List<AdminRow>> task = new Task<>() {
@Override protected List<AdminRow> call() {
JSONObject req = new JSONObject()
.put("action", "view_group_admins")
.put("group_id", groupId.toString())
.put("exclude_self", true) // 👈 سرور خودش Owner را حذف کند
.put("viewer_id", myUuidStr); // 👈 برای تشخیص self
JSONObject res = ActionHandler.sendWithResponse(req);
if (res == null || !"success".equalsIgnoreCase(res.optString("status"))) {
throw new RuntimeException(res != null ? res.optString("message","Failed to fetch admins.")
: "null response");
}
JSONArray arr = res.getJSONObject("data").optJSONArray("admins");
List<AdminRow> list = new ArrayList<>();
if (arr != null) {
for (int i = 0; i < arr.length(); i++) {
JSONObject a = arr.getJSONObject(i);
list.add(new AdminRow(
a.optString("internal_uuid",""),
a.optString("user_id",""),
a.optString("profile_name","Unknown"),
a.optString("avatar_url","")
));
}
}
return list;
}
};
task.setOnSucceeded(ev -> {
hideLoading(pi);
List<AdminRow> candidates = task.getValue();
if (candidates == null || candidates.isEmpty()) {
warn("No other admins available. Promote an admin first, then try again.");
return;
}
// اگر فقط یکی بود، سریع‌تر پیش برو
if (candidates.size() == 1) {
AdminRow target = candidates.get(0);
if (!confirm("Confirm Ownership Transfer",
"Transfer ownership to " + target.display() + "?\nYou will leave the group afterward.")) return;
transferThenLeaveAtomic(groupId, target, myUuidStr);
return;
}
// دیالوگ شیک با سرچ + لیست
Optional<AdminRow> pick = AdminPickerDialog.show("Transfer Ownership", "Select the new owner", candidates);
if (pick.isEmpty()) return;
AdminRow target = pick.get();
if (!confirm("Confirm Ownership Transfer",
"Transfer ownership to " + target.display() + "?\nYou will leave the group afterward.")) return;
transferThenLeaveAtomic(groupId, target, myUuidStr);
});
task.setOnFailed(ev -> {
hideLoading(pi);
error("Failed to load admins: " + safeMsg(task.getException()));
});
new Thread(task, "load-admins").start();
}
// ==== Prefer ATOMIC request; fallback to classic ====
private static void transferThenLeaveAtomic(UUID groupId, AdminRow target, String myUuidStr) {
ProgressIndicator pi = showLoading("Transferring & leaving...");
Task<Void> task = new Task<>() {
@Override protected Void call() {
// 1) Try atomic endpoint
JSONObject atomic = new JSONObject()
.put("action", "transfer_and_leave_group") // 👈 اکشن اتمیک
.put("group_id", groupId.toString())
.put("viewer_id", myUuidStr)
.put("new_owner_id", target.requireUuid().toString()); // UUID ضروری
JSONObject res = ActionHandler.sendWithResponse(atomic);
if (res != null && "success".equalsIgnoreCase(res.optString("status"))) {
return null; // done
}
// 2) Fallback: classic two-step
JSONObject tReq = new JSONObject()
.put("action", "transfer_group_ownership")
.put("group_id", groupId.toString())
.put("new_owner_id", target.requireUuid().toString());
JSONObject tRes = ActionHandler.sendWithResponse(tReq);
if (tRes == null || !"success".equalsIgnoreCase(tRes.optString("status"))) {
throw new RuntimeException(tRes != null ? tRes.optString("message","Ownership transfer failed.")
: "null response");
}
JSONObject lReq = new JSONObject()
.put("action", "leave_chat")
.put("user_id", myUuidStr)
.put("chat_id", groupId.toString())
.put("chat_type", "group");
JSONObject lRes = ActionHandler.sendWithResponse(lReq);
if (lRes == null || !"success".equalsIgnoreCase(lRes.optString("status"))) {
throw new RuntimeException(lRes != null ? lRes.optString("message","Leave failed.")
: "null response");
}
return null;
}
};
task.setOnSucceeded(ev -> {
hideLoading(pi);
info("Done. You left the group.");
// TODO: refresh chat list / close current view
});
task.setOnFailed(ev -> {
hideLoading(pi);
error("Operation failed: " + safeMsg(task.getException()));
});
new Thread(task, "transfer-leave").start();
}
// ==== direct leave for non-owner ====
private static void leaveGroupAsync(UUID groupId, String myUuidStr, Runnable onOk) {
ProgressIndicator pi = showLoading("Leaving group...");
Task<Void> task = new Task<>() {
@Override protected Void call() {
JSONObject req = new JSONObject()
.put("action", "leave_chat")
.put("user_id", myUuidStr)
.put("chat_id", groupId.toString())
.put("chat_type", "group");
JSONObject res = ActionHandler.sendWithResponse(req);
if (res == null || !"success".equalsIgnoreCase(res.optString("status"))) {
throw new RuntimeException(res != null ? res.optString("message","Leave failed.") : "null response");
}
return null;
}
};
task.setOnSucceeded(ev -> {
hideLoading(pi);
if (onOk != null) onOk.run();
});
task.setOnFailed(ev -> {
hideLoading(pi);
error("Leave failed: " + safeMsg(task.getException()));
});
new Thread(task, "leave-group").start();
}
// ====== Admin Picker Dialog (with search + avatars) ======
private static final class AdminPickerDialog {
static Optional<AdminRow> show(String title, String header, List<AdminRow> admins) {
Dialog<AdminRow> dialog = new Dialog<>();
dialog.setTitle(title);
dialog.setHeaderText(header);
ButtonType okType = new ButtonType("OK", ButtonBar.ButtonData.OK_DONE);
dialog.getDialogPane().getButtonTypes().addAll(okType, ButtonType.CANCEL);
TextField search = new TextField();
search.setPromptText("Search admin by name or id...");
search.setMinHeight(36);
ListView<AdminRow> list = new ListView<>();
list.getItems().setAll(admins);
list.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
list.setPrefWidth(480);
list.setPrefHeight(360);
list.setCellFactory(v -> new ListCell<>() {
private final HBox row = new HBox(10);
private final ImageView avatar = new ImageView();
private final Label name = new Label();
private final Label id = new Label();
{
row.setAlignment(Pos.CENTER_LEFT);
avatar.setFitWidth(36);
avatar.setFitHeight(36);
avatar.setPreserveRatio(true);
name.getStyleClass().add("admin-name");
id.getStyleClass().add("admin-id");
HBox text = new HBox(8, name, id);
row.getChildren().addAll(avatar, text);
}
@Override protected void updateItem(AdminRow it, boolean empty) {
super.updateItem(it, empty);
if (empty || it == null) {
setGraphic(null); setText(null);
} else {
name.setText(it.name);
id.setText("" + it.userId);
// آواتار (اختیاری): اگر URL هست نمایش بده
if (it.avatarUrl != null && !it.avatarUrl.isBlank()) {
try { avatar.setImage(new Image(it.avatarUrl, true)); }
catch (Exception e) { avatar.setImage(null); }
} else avatar.setImage(null);
setGraphic(row); setText(null);
}
}
});
// جستجو
search.textProperty().addListener((obs, o, n) -> {
String q = (n == null) ? "" : n.trim().toLowerCase(Locale.ROOT);
List<AdminRow> filtered = admins.stream()
.filter(a -> a.name.toLowerCase(Locale.ROOT).contains(q)
|| normalizeDisplayId(a.userId).contains(q))
.collect(Collectors.toList());
list.getItems().setAll(filtered);
});
// دابل کلیک = OK
list.setOnMouseClicked(e -> {
if (e.getClickCount() == 2 && list.getSelectionModel().getSelectedItem() != null) {
dialog.setResult(list.getSelectionModel().getSelectedItem());
dialog.close();
}
});
Node okBtn = dialog.getDialogPane().lookupButton(okType);
okBtn.setDisable(true);
list.getSelectionModel().selectedItemProperty().addListener((obs, old, sel) -> okBtn.setDisable(sel == null));
VBox content = new VBox(12, search, list);
content.setPadding(new Insets(10));
dialog.getDialogPane().setContent(content);
dialog.setResultConverter(bt -> bt == okType ? list.getSelectionModel().getSelectedItem() : null);
return dialog.showAndWait();
}
}
// ===== Models & helpers =====
private static final class AdminRow {
final UUID internalUuid;
final String userId;
final String name;
final String avatarUrl;
AdminRow(String internalUuidStr, String userId, String name, String avatarUrl) {
this.internalUuid = parseUuidOrNull(internalUuidStr);
this.userId = userId == null ? "" : userId;
this.name = name == null ? "Unknown" : name;
this.avatarUrl = avatarUrl;
}
UUID requireUuid() {
if (internalUuid == null)
throw new IllegalStateException("Admin has no internal_uuid; server must provide it.");
return internalUuid;
}
String display() { return name + " (" + userId + ")"; }
@Override public String toString() {
return "AdminRow{name='"+name+"', userId='"+userId+"', uuid="+internalUuid+"}";
}
}
private static String normalizeDisplayId(String s) {
if (s == null) return "";
String t = s.trim();
if (t.startsWith("@")) t = t.substring(1);
return t.toLowerCase(Locale.ROOT);
}
private static UUID parseUuidOrNull(String s) {
try { return (s == null || s.isBlank()) ? null : UUID.fromString(s); }
catch (Exception e) { return null; }
}
private static ProgressIndicator showLoading(String title) {
Alert a = new Alert(Alert.AlertType.NONE);
a.setTitle(title);
ProgressIndicator pi = new ProgressIndicator();
a.getDialogPane().setContent(pi);
a.getButtonTypes().clear();
a.show();
pi.getProperties().put("alert", a);
return pi;
}
private static void hideLoading(ProgressIndicator pi) {
Object a = pi.getProperties().get("alert");
if (a instanceof Alert) ((Alert) a).close();
}
static boolean confirm(String title, String msg) {
Alert a = new Alert(Alert.AlertType.CONFIRMATION, msg, ButtonType.OK, ButtonType.CANCEL);
a.setTitle(title);
Optional<ButtonType> r = a.showAndWait();
return r.isPresent() && r.get() == ButtonType.OK;
}
private static void info(String msg) { new Alert(Alert.AlertType.INFORMATION, msg, ButtonType.OK).show(); }
private static void warn(String msg) { new Alert(Alert.AlertType.WARNING, msg, ButtonType.OK).show(); }
private static void error(String msg) { new Alert(Alert.AlertType.ERROR, msg, ButtonType.OK).show(); }
private static String safeMsg(Throwable t) { return (t == null || t.getMessage() == null) ? "Unknown error" : t.getMessage(); }
}
@@ -1289,7 +1289,6 @@ public class MainController {
public void addChatAndSelect(org.to.telegramfinalproject.Models.ChatEntry entry) {
// در Session نگه‌داری
if (org.to.telegramfinalproject.Client.Session.chatList.stream()
.noneMatch(c -> c.getId().equals(entry.getId()))) {
org.to.telegramfinalproject.Client.Session.chatList.add(0, entry);
@@ -0,0 +1,271 @@
package org.to.telegramfinalproject.UI;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.collections.transformation.FilteredList;
import javafx.concurrent.Task;
import javafx.fxml.FXML;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
import org.json.JSONArray;
import org.json.JSONObject;
import org.to.telegramfinalproject.Client.ActionHandler;
import org.to.telegramfinalproject.Client.Session;
import java.util.*;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.UUID;
import static org.to.telegramfinalproject.UI.LeaveGroupFlow.confirm;
public class OwnerTransferDialogController {
@FXML private TextField searchField;
@FXML private ListView<AdminRow> adminList;
@FXML private Button okBtn;
@FXML private Button cancelBtn;
private UUID groupId;
private String viewerUuid; // internal UUID string
private Runnable onSuccess; // callback برای refresh UI
private FilteredList<AdminRow> filtered;
// ===== API =====
public void init(UUID groupId, String viewerUuid, Runnable onSuccess) {
this.groupId = groupId;
this.viewerUuid = viewerUuid;
this.onSuccess = onSuccess;
setupUI();
loadAdmins();
}
private void setupUI() {
okBtn.setDisable(true);
adminList.getSelectionModel().selectedItemProperty().addListener((obs, o, n) -> okBtn.setDisable(n == null));
cancelBtn.setOnAction(e -> cancel());
// Double-click = OK
adminList.setOnMouseClicked(e -> {
if (e.getClickCount() == 2 && adminList.getSelectionModel().getSelectedItem() != null) {
onOkClick();
}
});
// Cell factory (Avatar + name + id)
adminList.setCellFactory(v -> new ListCell<>() {
private final HBox row = new HBox(10);
private final ImageView avatar = new ImageView();
private final Label name = new Label();
private final Label id = new Label();
{
avatar.setFitWidth(32); avatar.setFitHeight(32); avatar.setPreserveRatio(true);
row.getChildren().addAll(avatar, name, id);
}
@Override protected void updateItem(AdminRow it, boolean empty) {
super.updateItem(it, empty);
if (empty || it == null) {
setGraphic(null); setText(null);
} else {
name.setText(it.name);
id.setText("" + it.userId);
if (it.avatarUrl != null && !it.avatarUrl.isBlank()) {
try { avatar.setImage(new Image(it.avatarUrl, true)); } catch (Exception ex) { avatar.setImage(null); }
} else avatar.setImage(null);
setGraphic(row); setText(null);
}
}
});
// search
searchField.textProperty().addListener((obs, o, n) -> {
final String q = (n == null) ? "" : n.trim().toLowerCase(Locale.ROOT);
filtered.setPredicate(makePredicate(q));
});
okBtn.setOnAction(e -> onOkClick());
}
private Predicate<AdminRow> makePredicate(String q) {
if (q.isBlank()) return r -> true;
return r -> r.name.toLowerCase(Locale.ROOT).contains(q)
|| normalizeDisplayId(r.userId).contains(q);
}
private void loadAdmins() {
ProgressIndicator pi = modalSpinner("Loading admins...");
Task<List<AdminRow>> t = new Task<>() {
@Override protected List<AdminRow> call() {
JSONObject req = new JSONObject()
.put("action", "view_group_admins")
.put("group_id", groupId.toString())
.put("exclude_self", true)
.put("viewer_id", viewerUuid);
JSONObject res = ActionHandler.sendWithResponse(req);
if (res == null || !"success".equalsIgnoreCase(res.optString("status"))) {
throw new RuntimeException(res != null ? res.optString("message","Failed to fetch admins.")
: "null response");
}
JSONArray arr = res.getJSONObject("data").optJSONArray("admins");
List<AdminRow> list = new ArrayList<>();
if (arr != null) {
for (int i = 0; i < arr.length(); i++) {
JSONObject a = arr.getJSONObject(i);
list.add(new AdminRow(
a.optString("internal_uuid",""),
a.optString("user_id",""),
a.optString("profile_name","Unknown"),
a.optString("avatar_url","")
));
}
}
return list;
}
};
t.setOnSucceeded(ev -> {
closeSpinner(pi);
List<AdminRow> admins = t.getValue();
if (admins == null) admins = List.of();
if (admins.isEmpty()) {
alert(Alert.AlertType.WARNING, "No other admins available. Promote an admin first, then try again.");
}
filtered = new FilteredList<>(FXCollections.observableArrayList(admins), r -> true);
adminList.setItems(filtered);
});
t.setOnFailed(ev -> {
closeSpinner(pi);
alert(Alert.AlertType.ERROR, "Failed to load admins: " + safeMsg(t.getException()));
});
new Thread(t, "load-admins").start();
}
private void onOkClick() {
AdminRow sel = adminList.getSelectionModel().getSelectedItem();
if (sel == null) return;
if (!confirm("Confirm Ownership Transfer",
"Transfer ownership to " + sel.display() + "?\nYou will leave the group afterward.")) return;
ProgressIndicator pi = modalSpinner("Transferring & leaving...");
Task<Void> t = new Task<>() {
@Override protected Void call() {
// 1) تلاش برای اکشن اتمیک
JSONObject atomic = new JSONObject()
.put("action", "transfer_and_leave_group")
.put("group_id", groupId.toString())
.put("viewer_id", viewerUuid)
.put("new_owner_id", sel.requireUuid().toString());
JSONObject res = ActionHandler.sendWithResponse(atomic);
if (res != null && "success".equalsIgnoreCase(res.optString("status"))) {
return null;
}
// 2) Fallback: دو مرحله‌ای
JSONObject tReq = new JSONObject()
.put("action", "transfer_group_ownership")
.put("group_id", groupId.toString())
.put("new_owner_id", sel.requireUuid().toString());
JSONObject tRes = ActionHandler.sendWithResponse(tReq);
if (tRes == null || !"success".equalsIgnoreCase(tRes.optString("status"))) {
throw new RuntimeException(tRes != null ? tRes.optString("message","Ownership transfer failed.")
: "null response");
}
JSONObject lReq = new JSONObject()
.put("action", "leave_chat")
.put("user_id", viewerUuid)
.put("chat_id", groupId.toString())
.put("chat_type", "group");
JSONObject lRes = ActionHandler.sendWithResponse(lReq);
if (lRes == null || !"success".equalsIgnoreCase(lRes.optString("status"))) {
throw new RuntimeException(lRes != null ? lRes.optString("message","Leave failed.")
: "null response");
}
return null;
}
};
t.setOnSucceeded(ev -> {
closeSpinner(pi);
alert(Alert.AlertType.INFORMATION, "Done. You left the group.");
closeDialog();
if (onSuccess != null) onSuccess.run();
});
t.setOnFailed(ev -> {
closeSpinner(pi);
alert(Alert.AlertType.ERROR, "Operation failed: " + safeMsg(t.getException()));
});
new Thread(t, "transfer-leave").start();
}
private void cancel() { closeDialog(); }
// ===== Utilities =====
private ProgressIndicator modalSpinner(String title) {
Alert a = new Alert(Alert.AlertType.NONE);
a.setTitle(title);
ProgressIndicator pi = new ProgressIndicator();
a.getDialogPane().setContent(pi);
a.getButtonTypes().clear();
a.show();
pi.getProperties().put("alert", a);
return pi;
}
private void closeSpinner(ProgressIndicator pi) {
Object a = pi.getProperties().get("alert");
if (a instanceof Alert) ((Alert)a).close();
}
private void closeDialog() {
// بستن پنجره میزبان کنترلر (DialogStage):
adminList.getScene().getWindow().hide();
}
private static String normalizeDisplayId(String s) {
if (s == null) return "";
String t = s.trim();
if (t.startsWith("@")) t = t.substring(1);
return t.toLowerCase(Locale.ROOT);
}
private static String safeMsg(Throwable t) { return (t == null || t.getMessage() == null) ? "Unknown error" : t.getMessage(); }
// ===== Row model =====
public static final class AdminRow {
public final UUID internalUuid;
public final String userId;
public final String name;
public final String avatarUrl;
public AdminRow(String internalUuidStr, String userId, String name, String avatarUrl) {
this.internalUuid = parseUuidOrNull(internalUuidStr);
this.userId = userId == null ? "" : userId;
this.name = name == null ? "Unknown" : name;
this.avatarUrl = avatarUrl;
}
public UUID requireUuid() {
if (internalUuid == null) throw new IllegalStateException("admin.internal_uuid missing");
return internalUuid;
}
public String display() { return name + " (" + userId + ")"; }
private static UUID parseUuidOrNull(String s) {
try { return (s == null || s.isBlank()) ? null : UUID.fromString(s); }
catch (Exception e) { return null; }
}
}
private static void alert(Alert.AlertType type, String msg) {
new Alert(type, msg, ButtonType.OK).show();
}
}
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.geometry.Insets?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<StackPane fx:id="root"
xmlns="http://javafx.com/javafx/21"
xmlns:fx="http://javafx.com/fxml/1"
fx:controller="org.to.telegramfinalproject.UI.ChatPageController">
<VBox spacing="0.0">
<MenuBar>
<menus>
<Menu text="Chat">
<items>
<MenuItem fx:id="leaveGroupItem" text="Leave Group"/>
</items>
</Menu>
</menus>
</MenuBar>
<BorderPane prefWidth="900.0" prefHeight="600.0">
<center>
<ListView fx:id="messagesList" />
</center>
<bottom>
<HBox spacing="8.0" padding="8.0">
<TextField fx:id="messageField" HBox.hgrow="ALWAYS" promptText="Type a message..."/>
<Button text="Send"/>
</HBox>
</bottom>
<padding>
<Insets top="8" right="8" bottom="8" left="8"/>
</padding>
</BorderPane>
</VBox>
<StackPane fx:id="overlay" visible="false" mouseTransparent="false"
style="-fx-background-color: rgba(0,0,0,0.35);">
<ProgressIndicator fx:id="loadingIndicator" maxWidth="80" maxHeight="80"/>
</StackPane>
</StackPane>
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB