Files
localsend-qt/src/app/qml/main.qml
2026-04-28 15:21:30 +08:00

753 lines
24 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Dialogs
ApplicationWindow {
id: root
visible: true
width: 800
height: 600
title: qsTr("LocalSend")
property string currentSessionId: ""
property var currentFiles: []
property string currentSenderAlias: ""
property string currentSenderIp: ""
DropArea {
id: dropArea
anchors.fill: parent
z: 9999
onEntered: function(drag) {
if (drag.hasUrls) {
drag.accepted = true
dropOverlay.visible = true
} else {
drag.accepted = false
}
}
onDropped: function(drop) {
dropOverlay.visible = false
if (drop.hasUrls) {
var paths = []
for (var i = 0; i < drop.urls.length; i++) {
paths.push(drop.urls[i].toString())
}
if (paths.length > 0) {
appController.addFiles(paths)
}
}
}
onExited: {
dropOverlay.visible = false
}
Rectangle {
id: dropOverlay
visible: false
anchors.fill: parent
color: "#200080FF"
z: 10000
Label {
anchors.centerIn: parent
text: qsTr("Drop files here to add them")
font.pixelSize: 24
font.bold: true
color: "#0080FF"
}
Rectangle {
anchors.fill: parent
anchors.margins: 8
color: "transparent"
radius: 12
border.color: "#0080FF"
border.width: 3
}
}
}
StackView {
id: stackView
anchors.fill: parent
Component.onCompleted: {
push(homePageComponent)
}
}
FileDialog {
id: fileDialog
title: qsTr("Select Files")
fileMode: FileDialog.OpenFiles
onAccepted: {
var paths = []
for (var i = 0; i < selectedFiles.length; i++) {
paths.push(selectedFiles[i].toString())
}
if (paths.length > 0) {
appController.addFiles(paths)
}
}
}
Dialog {
id: receiveDialog
anchors.centerIn: parent
modal: true
closePolicy: Popup.NoAutoClose
standardButtons: Dialog.Ok | Dialog.Cancel
title: qsTr("Receive Files")
onAccepted: {
appController.acceptReceive(currentSessionId)
receiveProgressDialog.open()
}
onRejected: {
appController.declineReceive(currentSessionId)
currentSessionId = ""
}
ColumnLayout {
spacing: 12
Label {
text: qsTr("From: %1 (%2)").arg(currentSenderAlias).arg(currentSenderIp)
font.bold: true
}
Label {
text: qsTr("Files:")
}
ListView {
Layout.fillWidth: true
Layout.preferredHeight: Math.min(200, contentHeight)
width: 400
model: currentFiles
spacing: 4
delegate: RowLayout {
width: ListView.view.width
spacing: 8
Label {
text: modelData.fileName
Layout.fillWidth: true
elide: Text.ElideMiddle
}
Label {
text: formatSize(modelData.size)
color: palette.mid
}
}
}
Label {
text: qsTr("Save to: %1").arg(appController.downloadPath)
color: palette.mid
font.pixelSize: 12
}
}
}
Dialog {
id: receiveProgressDialog
anchors.centerIn: parent
modal: true
closePolicy: Popup.NoAutoClose
title: qsTr("Receiving Files")
ColumnLayout {
spacing: 12
Label {
text: qsTr("From: %1").arg(appController.currentReceiveSenderAlias)
font.bold: true
}
Label {
text: appController.currentReceiveFileName
? qsTr("%1 (%2/%3)").arg(appController.currentReceiveFileName)
.arg(appController.currentReceiveFileIndex)
.arg(appController.totalReceiveFiles)
: qsTr("Waiting for data...")
elide: Text.ElideMiddle
Layout.maximumWidth: 400
}
ProgressBar {
Layout.fillWidth: true
from: 0
to: 100
value: appController.receiveProgress
}
Label {
text: qsTr("%1% complete").arg(Math.round(appController.receiveProgress))
color: palette.mid
}
}
footer: DialogButtonBox {
Button {
text: qsTr("Cancel")
DialogButtonBox.buttonRole: DialogButtonBox.RejectRole
onClicked: {
appController.cancelReceive()
receiveProgressDialog.close()
}
}
}
}
Dialog {
id: sendProgressDialog
anchors.centerIn: parent
modal: true
closePolicy: Popup.NoAutoClose
title: qsTr("Sending Files")
ColumnLayout {
spacing: 12
Label {
text: appController.currentSendFileName
? qsTr("%1 (%2/%3)").arg(appController.currentSendFileName)
.arg(appController.currentSendFileIndex)
.arg(appController.totalSendFiles)
: qsTr("Preparing...")
elide: Text.ElideMiddle
Layout.maximumWidth: 400
}
ProgressBar {
Layout.fillWidth: true
from: 0
to: 100
value: appController.sendProgress
}
Label {
text: qsTr("%1% complete").arg(Math.round(appController.sendProgress))
color: palette.mid
}
}
footer: DialogButtonBox {
Button {
text: qsTr("Cancel")
DialogButtonBox.buttonRole: DialogButtonBox.RejectRole
onClicked: {
appController.cancelSend()
sendProgressDialog.close()
}
}
}
}
Connections {
target: appController
function onReceiveRequest(sessionId, senderAlias, senderIp, files) {
currentSessionId = sessionId
currentSenderAlias = senderAlias
currentSenderIp = senderIp
currentFiles = files
if (appController.quickSave) {
appController.acceptReceive(sessionId)
receiveProgressDialog.open()
} else {
receiveDialog.open()
}
}
function onReceiveCompleted(sessionId) {
receiveProgressDialog.close()
}
function onReceiveError(sessionId, error) {
receiveProgressDialog.close()
errorDialog.text = error
errorDialog.open()
}
function onSendingChanged() {
if (appController.sending && !sendProgressDialog.visible) {
sendProgressDialog.open()
}
}
function onSendCompleted(sessionId) {
sendProgressDialog.close()
successDialog.text = qsTr("Files sent successfully!")
successDialog.open()
}
function onSendError(error) {
sendProgressDialog.close()
pinDialog.close()
errorDialog.text = error
errorDialog.open()
}
function onSendCanceled() {
sendProgressDialog.close()
errorDialog.text = qsTr("Transfer canceled by receiver")
errorDialog.open()
}
function onPinRequired(firstAttempt) {
pinDialog.isFirstAttempt = firstAttempt
pinDialog.open()
}
}
Dialog {
id: errorDialog
anchors.centerIn: parent
modal: true
standardButtons: Dialog.Ok
title: qsTr("Error")
property alias text: errorLabel.text
Label {
id: errorLabel
}
}
Dialog {
id: successDialog
anchors.centerIn: parent
modal: true
standardButtons: Dialog.Ok
title: qsTr("Success")
property alias text: successLabel.text
Label {
id: successLabel
}
}
Dialog {
id: pinDialog
anchors.centerIn: parent
modal: true
closePolicy: Popup.NoAutoClose
title: qsTr("PIN Required")
property bool isFirstAttempt: true
ColumnLayout {
spacing: 12
Label {
text: pinDialog.isFirstAttempt
? qsTr("The receiver requires a PIN to accept files.")
: qsTr("Invalid PIN. Please try again.")
wrapMode: Text.WordWrap
Layout.fillWidth: true
color: pinDialog.isFirstAttempt ? palette.text : "red"
}
TextField {
id: pinInput
Layout.fillWidth: true
placeholderText: qsTr("Enter PIN")
echoMode: TextInput.Password
focus: true
onAccepted: pinDialog.submitPin()
}
}
footer: DialogButtonBox {
Button {
text: qsTr("Cancel")
DialogButtonBox.buttonRole: DialogButtonBox.RejectRole
}
Button {
text: qsTr("Submit")
DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole
enabled: pinInput.text.length > 0
}
}
onAccepted: submitPin()
onRejected: {
appController.cancelSend()
sendProgressDialog.close()
close()
}
function submitPin() {
if (pinInput.text.length > 0) {
appController.retryWithPin(pinInput.text)
pinInput.text = ""
}
}
onOpened: {
pinInput.text = ""
pinInput.forceActiveFocus()
}
}
Dialog {
id: setPinDialog
anchors.centerIn: parent
modal: true
title: qsTr("Set Receive PIN")
ColumnLayout {
spacing: 12
Label {
text: qsTr("Enter a PIN that senders must provide to transfer files to this device. Leave empty to disable.")
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
TextField {
id: setPinInput
Layout.fillWidth: true
placeholderText: qsTr("Enter PIN")
onAccepted: setPinDialog.accepted()
}
}
footer: DialogButtonBox {
Button {
text: qsTr("Cancel")
DialogButtonBox.buttonRole: DialogButtonBox.RejectRole
}
Button {
text: qsTr("OK")
DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole
}
}
onAccepted: {
appController.receivePin = setPinInput.text
}
onOpened: {
setPinInput.text = appController.receivePin
setPinInput.forceActiveFocus()
}
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + " B"
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + " MB"
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB"
}
Component {
id: homePageComponent
Page {
id: homePage
signal openSettings()
header: ToolBar {
RowLayout {
anchors.fill: parent
Label {
text: qsTr("LocalSend")
font.bold: true
font.pixelSize: 20
Layout.leftMargin: 16
}
Item { Layout.fillWidth: true }
ToolButton {
text: qsTr("Settings")
onClicked: homePage.openSettings()
}
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 16
spacing: 16
ColumnLayout {
Layout.fillWidth: true
spacing: 8
RowLayout {
Layout.fillWidth: true
Label {
text: qsTr("Selected Files")
font.bold: true
font.pixelSize: 16
}
Item { Layout.fillWidth: true }
Button {
text: qsTr("Add Files")
onClicked: fileDialog.open()
}
Button {
text: qsTr("Clear All")
visible: appController.hasPendingFiles
onClicked: appController.clearPendingFiles()
}
}
ListView {
id: pendingFilesList
Layout.fillWidth: true
Layout.preferredHeight: Math.min(180, contentHeight)
model: appController.pendingFiles
spacing: 4
visible: appController.hasPendingFiles
clip: true
delegate: Pane {
width: ListView.view.width
padding: 8
background: Rectangle {
color: "transparent"
radius: 6
border.color: palette.mid
border.width: 1
}
RowLayout {
anchors.fill: parent
spacing: 8
Label {
text: modelData.fileName
Layout.fillWidth: true
elide: Text.ElideMiddle
}
Label {
text: formatSize(modelData.size)
color: palette.mid
font.pixelSize: 12
}
ToolButton {
text: "\u2715"
font.pixelSize: 14
onClicked: appController.removePendingFile(index)
}
}
}
}
Label {
visible: !appController.hasPendingFiles
text: qsTr("No files selected. Click \"Add Files\" or drag and drop files here.")
color: palette.mid
Layout.fillWidth: true
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHCenter
}
}
Label {
text: qsTr("Nearby Devices")
font.bold: true
font.pixelSize: 16
}
ListView {
id: deviceListView
Layout.fillWidth: true
Layout.fillHeight: true
property var devices: appController.devices
model: devices
spacing: 8
delegate: Pane {
width: ListView.view.width
padding: 12
background: Rectangle {
color: "transparent"
radius: 8
border.color: palette.mid
border.width: 1
}
RowLayout {
anchors.fill: parent
Column {
Layout.fillWidth: true
Label {
text: modelData.alias || modelData.ip
font.bold: true
color: palette.text
}
Label {
text: "%1:%2".arg(modelData.ip).arg(modelData.port)
color: palette.mid
font.pixelSize: 12
}
}
Button {
visible: appController.hasPendingFiles && !appController.sending
text: qsTr("Send")
enabled: !appController.sending
onClicked: {
if (appController.hasPendingFiles && !appController.sending) {
appController.sendTo(modelData.fingerprint)
}
}
}
}
}
Label {
anchors.centerIn: parent
text: qsTr("No devices found")
color: palette.mid
visible: deviceListView.count === 0
}
}
RowLayout {
Layout.fillWidth: true
Button {
text: qsTr("Refresh")
onClicked: appController.refreshDevices()
}
Item { Layout.fillWidth: true }
Label {
text: qsTr("Alias: %1").arg(appController.alias)
color: palette.mid
}
}
}
onOpenSettings: stackView.push(settingsPageComponent)
}
}
Component {
id: settingsPageComponent
Page {
id: settingsPage
signal back()
header: ToolBar {
RowLayout {
anchors.fill: parent
ToolButton {
text: qsTr("Back")
onClicked: settingsPage.back()
}
Label {
text: qsTr("Settings")
font.bold: true
Layout.fillWidth: true
Layout.leftMargin: 16
}
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 16
spacing: 16
GridLayout {
columns: 2
Layout.fillWidth: true
Label { text: qsTr("Device Alias:") }
TextField {
id: aliasField
text: appController.alias
onEditingFinished: appController.alias = text
Layout.fillWidth: true
}
Label { text: qsTr("Port:") }
SpinBox {
id: portField
value: appController.port
from: 1
to: 65535
onValueChanged: appController.port = value
}
Label { text: qsTr("Download Path:") }
RowLayout {
Layout.fillWidth: true
TextField {
id: downloadPathField
text: appController.downloadPath
onEditingFinished: appController.downloadPath = text
Layout.fillWidth: true
}
Button {
text: qsTr("Browse")
onClicked: folderDialog.open()
}
}
Label { text: qsTr("Quick Save:") }
CheckBox {
id: quickSaveCheck
checked: appController.quickSave
onCheckedChanged: appController.quickSave = checked
}
Label { text: qsTr("Receive PIN:") }
RowLayout {
Layout.fillWidth: true
Label {
text: appController.receivePin.length > 0 ? qsTr("Enabled") : qsTr("Disabled")
color: appController.receivePin.length > 0 ? "green" : palette.mid
}
Item { Layout.fillWidth: true }
Button {
text: appController.receivePin.length > 0 ? qsTr("Change") : qsTr("Set PIN")
onClicked: setPinDialog.open()
}
Button {
text: qsTr("Remove")
visible: appController.receivePin.length > 0
onClicked: appController.receivePin = ""
}
}
}
FolderDialog {
id: folderDialog
onAccepted: {
downloadPathField.text = selectedFolder.toString().replace("file://", "")
appController.downloadPath = downloadPathField.text
}
}
Item { Layout.fillHeight: true }
Label {
text: qsTr("Server Status: %1").arg(appController.serverRunning ? qsTr("Running") : qsTr("Stopped"))
color: appController.serverRunning ? "green" : "red"
}
}
onBack: stackView.pop()
}
}
}