753 lines
24 KiB
QML
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()
|
|
}
|
|
}
|
|
}
|