feat(ui): login + conflict dialogs (Qt)
Login dialog (prompt_login_qt): Modal asking for a discord_id:password token. Echo masked. Validates format before accepting. 'Skip cloud sync' returns None so caller can bypass sync without blocking the launch. Conflict dialog (prompt_conflict_qt): Modal with three radio options — keep_local, use_remote, cancel. Shown when local files were modified since the last pulled snapshot AND the remote has a newer snapshot from another host (divergence state.json detection happens in sync.pull; not wired yet). Both reuse the Prism dark palette via _apply_prism_dark. Tk fallbacks not implemented — Qt is the path most players will hit. Next: state.json (last_pulled_snapshot_id), divergence detection in sync.pull, integration with these dialogs.
This commit is contained in:
@@ -184,3 +184,171 @@ class QtProgressWindow:
|
|||||||
|
|
||||||
def _on_finished(self, _rc: int) -> None:
|
def _on_finished(self, _rc: int) -> None:
|
||||||
self._dialog.accept()
|
self._dialog.accept()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Login dialog (first-run token capture)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_login_qt() -> str | None:
|
||||||
|
"""Modal dialog to collect a fresh ``discord_id:password`` token.
|
||||||
|
|
||||||
|
Returned string is the validated raw token (caller writes to disk).
|
||||||
|
Returns ``None`` if the user picked "Skip cloud sync" or closed the
|
||||||
|
dialog — caller should treat that as "don't sync, don't block launch."
|
||||||
|
"""
|
||||||
|
QtWidgets, QtCore, _ = import_qt()
|
||||||
|
app_existed = QtWidgets.QApplication.instance() is not None
|
||||||
|
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv)
|
||||||
|
if not app_existed:
|
||||||
|
_apply_prism_dark(app)
|
||||||
|
|
||||||
|
dialog = QtWidgets.QDialog()
|
||||||
|
dialog.setWindowTitle("Cloud sync — first time setup")
|
||||||
|
dialog.setFixedSize(480, 260)
|
||||||
|
|
||||||
|
layout = QtWidgets.QVBoxLayout(dialog)
|
||||||
|
layout.setContentsMargins(20, 20, 20, 20)
|
||||||
|
layout.setSpacing(10)
|
||||||
|
|
||||||
|
title = QtWidgets.QLabel("Connect to cloud save")
|
||||||
|
font = title.font()
|
||||||
|
font.setBold(True)
|
||||||
|
font.setPointSize(font.pointSize() + 2)
|
||||||
|
title.setFont(font)
|
||||||
|
layout.addWidget(title)
|
||||||
|
|
||||||
|
body = QtWidgets.QLabel(
|
||||||
|
"In Discord, message the bot:\n"
|
||||||
|
" /cloud register\n\n"
|
||||||
|
"It will DM you a one-line token. Paste it below:"
|
||||||
|
)
|
||||||
|
body.setWordWrap(True)
|
||||||
|
layout.addWidget(body)
|
||||||
|
|
||||||
|
field = QtWidgets.QLineEdit()
|
||||||
|
field.setPlaceholderText("123456789012345678:a1b2c3d4…")
|
||||||
|
field.setEchoMode(QtWidgets.QLineEdit.EchoMode.Password)
|
||||||
|
layout.addWidget(field)
|
||||||
|
|
||||||
|
error = QtWidgets.QLabel("")
|
||||||
|
error.setStyleSheet("color: #ff6b6b;")
|
||||||
|
layout.addWidget(error)
|
||||||
|
|
||||||
|
layout.addStretch(1)
|
||||||
|
|
||||||
|
button_row = QtWidgets.QHBoxLayout()
|
||||||
|
skip = QtWidgets.QPushButton("Skip cloud sync")
|
||||||
|
button_row.addWidget(skip)
|
||||||
|
button_row.addStretch(1)
|
||||||
|
save = QtWidgets.QPushButton("Save and continue")
|
||||||
|
save.setDefault(True)
|
||||||
|
button_row.addWidget(save)
|
||||||
|
layout.addLayout(button_row)
|
||||||
|
|
||||||
|
chosen: dict[str, str | None] = {"value": None}
|
||||||
|
|
||||||
|
def on_save() -> None:
|
||||||
|
token = field.text().strip()
|
||||||
|
if ":" not in token:
|
||||||
|
error.setText("Token must be discord_id:password")
|
||||||
|
return
|
||||||
|
head, sep, tail = token.partition(":")
|
||||||
|
if not head.isdigit() or not tail:
|
||||||
|
error.setText("discord_id must be numeric; password must be non-empty")
|
||||||
|
return
|
||||||
|
chosen["value"] = token
|
||||||
|
dialog.accept()
|
||||||
|
|
||||||
|
def on_skip() -> None:
|
||||||
|
chosen["value"] = None
|
||||||
|
dialog.reject()
|
||||||
|
|
||||||
|
save.clicked.connect(on_save)
|
||||||
|
skip.clicked.connect(on_skip)
|
||||||
|
field.returnPressed.connect(on_save)
|
||||||
|
|
||||||
|
dialog.exec()
|
||||||
|
return chosen["value"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Conflict dialog (local-modified-since-last-sync + remote-newer)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_conflict_qt(
|
||||||
|
local_summary: str,
|
||||||
|
remote_summary: str,
|
||||||
|
) -> str:
|
||||||
|
"""Ask how to resolve a divergence.
|
||||||
|
|
||||||
|
Returns one of: ``"keep_local"``, ``"use_remote"``, ``"cancel"``.
|
||||||
|
"""
|
||||||
|
QtWidgets, QtCore, _ = import_qt()
|
||||||
|
app_existed = QtWidgets.QApplication.instance() is not None
|
||||||
|
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv)
|
||||||
|
if not app_existed:
|
||||||
|
_apply_prism_dark(app)
|
||||||
|
|
||||||
|
dialog = QtWidgets.QDialog()
|
||||||
|
dialog.setWindowTitle("Cloud sync — conflict")
|
||||||
|
dialog.setFixedSize(520, 320)
|
||||||
|
|
||||||
|
layout = QtWidgets.QVBoxLayout(dialog)
|
||||||
|
layout.setContentsMargins(20, 20, 20, 20)
|
||||||
|
layout.setSpacing(10)
|
||||||
|
|
||||||
|
title = QtWidgets.QLabel("Local and cloud diverged")
|
||||||
|
font = title.font()
|
||||||
|
font.setBold(True)
|
||||||
|
font.setPointSize(font.pointSize() + 2)
|
||||||
|
title.setFont(font)
|
||||||
|
layout.addWidget(title)
|
||||||
|
|
||||||
|
summary = QtWidgets.QLabel(
|
||||||
|
f"Local changes since last sync:\n {local_summary}\n\n"
|
||||||
|
f"Cloud has newer changes from another machine:\n {remote_summary}\n\n"
|
||||||
|
f"What should we do?"
|
||||||
|
)
|
||||||
|
summary.setWordWrap(True)
|
||||||
|
layout.addWidget(summary)
|
||||||
|
|
||||||
|
keep_local = QtWidgets.QRadioButton(
|
||||||
|
"Keep local — skip pull. Next exit will overwrite cloud."
|
||||||
|
)
|
||||||
|
use_remote = QtWidgets.QRadioButton(
|
||||||
|
"Use cloud — overwrite local files with the cloud version."
|
||||||
|
)
|
||||||
|
cancel_launch = QtWidgets.QRadioButton(
|
||||||
|
"Cancel launch — don't start Minecraft until I resolve manually."
|
||||||
|
)
|
||||||
|
keep_local.setChecked(True)
|
||||||
|
layout.addWidget(keep_local)
|
||||||
|
layout.addWidget(use_remote)
|
||||||
|
layout.addWidget(cancel_launch)
|
||||||
|
|
||||||
|
layout.addStretch(1)
|
||||||
|
|
||||||
|
button_row = QtWidgets.QHBoxLayout()
|
||||||
|
button_row.addStretch(1)
|
||||||
|
confirm = QtWidgets.QPushButton("Continue")
|
||||||
|
confirm.setDefault(True)
|
||||||
|
button_row.addWidget(confirm)
|
||||||
|
layout.addLayout(button_row)
|
||||||
|
|
||||||
|
chosen: dict[str, str] = {"value": "cancel"}
|
||||||
|
|
||||||
|
def on_confirm() -> None:
|
||||||
|
if keep_local.isChecked():
|
||||||
|
chosen["value"] = "keep_local"
|
||||||
|
elif use_remote.isChecked():
|
||||||
|
chosen["value"] = "use_remote"
|
||||||
|
else:
|
||||||
|
chosen["value"] = "cancel"
|
||||||
|
dialog.accept()
|
||||||
|
|
||||||
|
confirm.clicked.connect(on_confirm)
|
||||||
|
dialog.exec()
|
||||||
|
return chosen["value"]
|
||||||
|
|||||||
Reference in New Issue
Block a user