Bluetooth Low Energy Heart Rate Game

Tags: Android

The Bluetooth Low Energy Heart Rate Game shows how to develop a Bluetooth Low Energy application using the Qt Bluetooth API. The application covers the scanning for Bluetooth Low Energy devices, connecting to a Heart Rate service on the device, writing characteristics and descriptors, and receiving updates from the device once the heart rate has changed.

The command line option –simulator can be used to run the example against a demo server in case no Bluetooth hardware is available.

Download this example

# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from __future__ import annotations

"""PySide6 port of the bluetooth/heartrate-game example from Qt v6.x"""

from pathlib import Path
import sys
from argparse import ArgumentParser, RawDescriptionHelpFormatter

from PySide6.QtQml import QQmlApplicationEngine
from PySide6.QtGui import QGuiApplication
from PySide6.QtCore import QCoreApplication, QLoggingCategory

from connectionhandler import ConnectionHandler
from devicefinder import DeviceFinder
from devicehandler import DeviceHandler
from bluetoothbaseclass import BluetoothBaseClass  # noqa: F401
from heartrate_global import set_simulator


if __name__ == '__main__':
    parser = ArgumentParser(prog="heartrate-game",
                            formatter_class=RawDescriptionHelpFormatter)

    parser.add_argument("-v", "--verbose", action="store_true",
                        help="Generate more output")
    parser.add_argument("-s", "--simulator", action="store_true",
                        help="Use Simulator")
    options = parser.parse_args()
    set_simulator(options.simulator)
    if options.verbose:
        QLoggingCategory.setFilterRules("qt.bluetooth* = true")

    app = QGuiApplication(sys.argv)

    connectionHandler = ConnectionHandler()
    deviceHandler = DeviceHandler()
    deviceFinder = DeviceFinder(deviceHandler)

    engine = QQmlApplicationEngine()
    engine.setInitialProperties({
        "connectionHandler": connectionHandler,
        "deviceFinder": deviceFinder,
        "deviceHandler": deviceHandler})

    engine.addImportPath(Path(__file__).parent)
    engine.loadFromModule("HeartRateGame", "Main")

    if not engine.rootObjects():
        sys.exit(-1)

    exit_code = QCoreApplication.exec()
    del engine
    sys.exit(exit_code)
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from __future__ import annotations

from enum import IntEnum

from PySide6.QtQml import QmlElement, QmlUncreatable
from PySide6.QtCore import QObject, Property, Signal, Slot, QEnum

QML_IMPORT_NAME = "HeartRateGame"
QML_IMPORT_MAJOR_VERSION = 1


@QmlElement
@QmlUncreatable("BluetoothBaseClass is not intended to be created directly")
class BluetoothBaseClass(QObject):

    @QEnum
    class IconType(IntEnum):
        IconNone = 0
        IconBluetooth = 1
        IconError = 2
        IconProgress = 3
        IconSearch = 4

    errorChanged = Signal()
    infoChanged = Signal()
    iconChanged = Signal()

    def __init__(self, parent=None):
        super().__init__(parent)
        self.m_error = ""
        self.m_info = ""
        self.m_icon = BluetoothBaseClass.IconType.IconNone

    @Property(str, notify=errorChanged)
    def error(self):
        return self.m_error

    @error.setter
    def error(self, e):
        if self.m_error != e:
            self.m_error = e
            self.errorChanged.emit()

    @Property(str, notify=infoChanged)
    def info(self):
        return self.m_info

    @info.setter
    def info(self, i):
        if self.m_info != i:
            self.m_info = i
            self.infoChanged.emit()

    @Property(int, notify=iconChanged)
    def icon(self):
        return self.m_icon

    @icon.setter
    def icon(self, i):
        if self.m_icon != i:
            self.m_icon = i
            self.iconChanged.emit()

    @Slot()
    def clearMessages(self):
        self.info = ""
        self.error = ""
        self.icon = BluetoothBaseClass.IconType.IconNone
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from __future__ import annotations

import sys

from PySide6.QtBluetooth import QBluetoothLocalDevice
from PySide6.QtQml import QmlElement
from PySide6.QtCore import QObject, Property, Signal, Slot, Qt

from heartrate_global import simulator, is_android, error_not_nuitka

if is_android or sys.platform == "darwin":
    from PySide6.QtCore import QBluetoothPermission

# To be used on the @QmlElement decorator
# (QML_IMPORT_MINOR_VERSION is optional)
QML_IMPORT_NAME = "HeartRateGame"
QML_IMPORT_MAJOR_VERSION = 1


@QmlElement
class ConnectionHandler(QObject):

    deviceChanged = Signal()

    def __init__(self, parent=None):
        super().__init__(parent)
        self.m_hasPermission = False
        self.initLocalDevice()

    @Property(bool, notify=deviceChanged)
    def alive(self):
        if sys.platform == "darwin":
            return True
        if simulator():
            return True
        return (self.m_localDevice.isValid()
                and self.m_localDevice.hostMode() != QBluetoothLocalDevice.HostPoweredOff)

    @Property(bool, constant=True)
    def requiresAddressType(self):
        return sys.platform == "linux"  # QT_CONFIG(bluez)?

    @Property(str, notify=deviceChanged)
    def name(self):
        return self.m_localDevice.name()

    @Property(str, notify=deviceChanged)
    def address(self):
        return self.m_localDevice.address().toString()

    @Property(bool, notify=deviceChanged)
    def hasPermission(self):
        return self.m_hasPermission

    @Slot(QBluetoothLocalDevice.HostMode)
    def hostModeChanged(self, mode):
        self.deviceChanged.emit()

    def initLocalDevice(self):
        if is_android or sys.platform == "darwin":
            error_not_nuitka()
            permission = QBluetoothPermission()
            permission.setCommunicationModes(QBluetoothPermission.Access)
            permission_status = qApp.checkPermission(permission)  # noqa: F821
            if permission_status == Qt.PermissionStatus.Undetermined:
                qApp.requestPermission(permission, self, self.initLocalDevice)  # noqa: F821
                return
            if permission_status == Qt.PermissionStatus.Denied:
                return
            elif permission_status == Qt.PermissionStatus.Granted:
                print("[HeartRateGame] Bluetooth Permission Granted")

        self.m_localDevice = QBluetoothLocalDevice()
        self.m_localDevice.hostModeStateChanged.connect(self.hostModeChanged)
        self.m_hasPermission = True
        self.deviceChanged.emit()
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from __future__ import annotations
import sys

from PySide6.QtBluetooth import (QBluetoothDeviceDiscoveryAgent,
                                 QBluetoothDeviceInfo)
from PySide6.QtQml import QmlElement, QmlUncreatable
from PySide6.QtCore import QTimer, Property, Signal, Slot, Qt

from bluetoothbaseclass import BluetoothBaseClass
from deviceinfo import DeviceInfo
from heartrate_global import simulator, is_android, error_not_nuitka

if is_android or sys.platform == "darwin":
    from PySide6.QtCore import QBluetoothPermission

# To be used on the @QmlElement decorator
# (QML_IMPORT_MINOR_VERSION is optional)
QML_IMPORT_NAME = "HeartRateGame"
QML_IMPORT_MAJOR_VERSION = 1


@QmlElement
@QmlUncreatable("This class is not intended to be created directly")
class DeviceFinder(BluetoothBaseClass):

    scanningChanged = Signal()
    devicesChanged = Signal()

    def __init__(self, handler, parent=None):
        super().__init__(parent)
        self.m_deviceHandler = handler
        self.m_devices = []
        self.m_demoTimer = QTimer()
#! [devicediscovery-1]
        self.m_deviceDiscoveryAgent = QBluetoothDeviceDiscoveryAgent(self)
        self.m_deviceDiscoveryAgent.setLowEnergyDiscoveryTimeout(15000)
        self.m_deviceDiscoveryAgent.deviceDiscovered.connect(self.addDevice)
        self.m_deviceDiscoveryAgent.errorOccurred.connect(self.scanError)

        self.m_deviceDiscoveryAgent.finished.connect(self.scanFinished)
        self.m_deviceDiscoveryAgent.canceled.connect(self.scanFinished)
#! [devicediscovery-1]
        if simulator():
            self.m_demoTimer.setSingleShot(True)
            self.m_demoTimer.setInterval(2000)
            self.m_demoTimer.timeout.connect(self.scanFinished)

    @Slot()
    def startSearch(self):
        if is_android or sys.platform == "darwin":
            error_not_nuitka()
            permission = QBluetoothPermission()
            permission.setCommunicationModes(QBluetoothPermission.Access)
            permission_status = qApp.checkPermission(permission)  # noqa: F821
            if permission_status == Qt.PermissionStatus.Undetermined:
                qApp.requestPermission(permission, self, self.startSearch)  # noqa: F82 1
                return
            elif permission_status == Qt.PermissionStatus.Denied:
                self.icon = BluetoothBaseClass.IconType.IconError
                return
            elif permission_status == Qt.PermissionStatus.Granted:
                print("[HeartRateGame] Bluetooth Permission Granted")

        self.clearMessages()
        self.m_deviceHandler.setDevice(None)
        self.m_devices.clear()

        self.devicesChanged.emit()

        if simulator():
            self.m_demoTimer.start()
        else:
#! [devicediscovery-2]
            self.m_deviceDiscoveryAgent.start(QBluetoothDeviceDiscoveryAgent.LowEnergyMethod)
#! [devicediscovery-2]
            self.scanningChanged.emit()
        self.info = "Scanning for devices..."
        self.icon = BluetoothBaseClass.IconType.IconProgress

#! [devicediscovery-3]
    @Slot(QBluetoothDeviceInfo)
    def addDevice(self, device):
        # If device is LowEnergy-device, add it to the list
        if device.coreConfigurations() & QBluetoothDeviceInfo.LowEnergyCoreConfiguration:
            self.m_devices.append(DeviceInfo(device))
            self.info = "Low Energy device found. Scanning more..."
            self.icon = BluetoothBaseClass.IconType.IconProgress
#! [devicediscovery-3]
            self.devicesChanged.emit()
#! [devicediscovery-4]
    #...
#! [devicediscovery-4]

    @Slot(QBluetoothDeviceDiscoveryAgent.Error)
    def scanError(self, error):
        if error == QBluetoothDeviceDiscoveryAgent.PoweredOffError:
            self.error = "The Bluetooth adaptor is powered off."
        elif error == QBluetoothDeviceDiscoveryAgent.InputOutputError:
            self.error = "Writing or reading from the device resulted in an error."
        else:
            self.error = "An unknown error has occurred."
        self.icon = BluetoothBaseClass.IconType.IconError

    @Slot()
    def scanFinished(self):
        if simulator():
            # Only for testing
            for i in range(5):
                self.m_devices.append(DeviceInfo(QBluetoothDeviceInfo()))

        if self.m_devices:
            self.info = "Scanning done."
            self.icon = BluetoothBaseClass.IconType.IconBluetooth
        else:
            self.error = "No Low Energy devices found."
            self.icon = BluetoothBaseClass.IconType.IconError

        self.scanningChanged.emit()
        self.devicesChanged.emit()

    @Slot()
    def resetMessages(self):
        self.error = ""
        self.info = "Start search to find devices"
        self.icon = BluetoothBaseClass.IconType.IconSearch

    @Slot(str)
    def connectToService(self, address):
        self.m_deviceDiscoveryAgent.stop()

        currentDevice = None
        for entry in self.m_devices:
            device = entry
            if device and device.deviceAddress == address:
                currentDevice = device
                break

        if currentDevice:
            self.m_deviceHandler.setDevice(currentDevice)

        self.resetMessages()

    @Property(bool, notify=scanningChanged)
    def scanning(self):
        if simulator():
            return self.m_demoTimer.isActive()
        return self.m_deviceDiscoveryAgent.isActive()

    @Property("QVariant", notify=devicesChanged)
    def devices(self):
        return self.m_devices
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from __future__ import annotations

import struct

from enum import IntEnum

from PySide6.QtBluetooth import (QLowEnergyCharacteristic,
                                 QLowEnergyController,
                                 QLowEnergyDescriptor,
                                 QLowEnergyService,
                                 QBluetoothUuid)
from PySide6.QtQml import QmlElement
from PySide6.QtCore import (QByteArray, QDateTime, QRandomGenerator, QTimer,
                            Property, Signal, Slot, QEnum)

from bluetoothbaseclass import BluetoothBaseClass
from heartrate_global import simulator


# To be used on the @QmlElement decorator
# (QML_IMPORT_MINOR_VERSION is optional)
QML_IMPORT_NAME = "HeartRateGame"
QML_IMPORT_MAJOR_VERSION = 1


@QmlElement
class DeviceHandler(BluetoothBaseClass):

    @QEnum
    class AddressType(IntEnum):
        PUBLIC_ADDRESS = 1
        RANDOM_ADDRESS = 2

    measuringChanged = Signal()
    aliveChanged = Signal()
    statsChanged = Signal()

    def __init__(self, parent=None):
        super().__init__(parent)

        self.m_control = None
        self.m_service = None
        self.m_notificationDesc = QLowEnergyDescriptor()
        self.m_currentDevice = None

        self.m_foundHeartRateService = False
        self.m_measuring = False
        self.m_currentValue = 0
        self.m_min = 0
        self.m_max = 0
        self.m_sum = 0
        self.m_avg = 0.0
        self.m_calories = 0.0

        self.m_start = QDateTime()
        self.m_stop = QDateTime()

        self.m_measurements = []
        self.m_addressType = QLowEnergyController.RemoteAddressType.PublicAddress

        self.m_demoTimer = QTimer()

        if simulator():
            self.m_demoTimer.setSingleShot(False)
            self.m_demoTimer.setInterval(2000)
            self.m_demoTimer.timeout.connect(self.updateDemoHR)
            self.m_demoTimer.start()
            self.updateDemoHR()

    def addressType(self):
        if self.m_addressType == QLowEnergyController.RemoteAddressType.RandomAddress:
            return DeviceHandler.AddressType.RANDOM_ADDRESS
        return DeviceHandler.AddressType.PUBLIC_ADDRESS

    @Slot(int)
    def setAddressType(self, type):
        if type == DeviceHandler.AddressType.PUBLIC_ADDRESS:
            self.m_addressType = QLowEnergyController.RemoteAddressType.PublicAddress
        elif type == DeviceHandler.AddressType.RANDOM_ADDRESS:
            self.m_addressType = QLowEnergyController.RemoteAddressType.RandomAddress

    @Slot()
    def resetAddressType(self):
        self.m_addressType = QLowEnergyController.RemoteAddressType.PublicAddress

    @Slot(QLowEnergyController.Error)
    def controllerErrorOccurred(self, device):
        self.error = "Cannot connect to remote device."
        self.icon = BluetoothBaseClass.IconType.IconError

    @Slot()
    def controllerConnected(self):
        self.info = "Controller connected. Search services..."
        self.icon = BluetoothBaseClass.IconType.IconProgress
        self.m_control.discoverServices()

    @Slot()
    def controllerDisconnected(self):
        self.error = "LowEnergy controller disconnected"
        self.icon = BluetoothBaseClass.IconType.IconError

    def setDevice(self, device):
        self.clearMessages()
        self.m_currentDevice = device

        if simulator():
            self.info = "Demo device connected."
            self.icon = BluetoothBaseClass.IconType.IconBluetooth
            return

        # Disconnect and delete old connection
        if self.m_control:
            self.m_control.disconnectFromDevice()
            self.m_control = None

        # Create new controller and connect it if device available
        if self.m_currentDevice:

            # Make connections
#! [Connect-Signals-1]
            self.m_control = QLowEnergyController.createCentral(self.m_currentDevice.device(), self)
#! [Connect-Signals-1]
            self.m_control.setRemoteAddressType(self.m_addressType)
#! [Connect-Signals-2]

            self.m_control.serviceDiscovered.connect(self.serviceDiscovered)
            self.m_control.discoveryFinished.connect(self.serviceScanDone)

            self.m_control.errorOccurred.connect(self.controllerErrorOccurred)
            self.m_control.connected.connect(self.controllerConnected)
            self.m_control.disconnected.connect(self.controllerDisconnected)

            # Connect
            self.m_control.connectToDevice()
#! [Connect-Signals-2]

    @Slot()
    def startMeasurement(self):
        if self.alive:
            self.m_start = QDateTime.currentDateTime()
            self.m_min = 0
            self.m_max = 0
            self.m_avg = 0
            self.m_sum = 0
            self.m_calories = 0.0
            self.m_measuring = True
            self.m_measurements.clear()
            self.measuringChanged.emit()

    @Slot()
    def stopMeasurement(self):
        self.m_measuring = False
        self.measuringChanged.emit()

#! [Filter HeartRate service 1]
    @Slot(QBluetoothUuid)
    def serviceDiscovered(self, gatt):
        if gatt == QBluetoothUuid(QBluetoothUuid.ServiceClassUuid.HeartRate):
            self.info = "Heart Rate service discovered. Waiting for service scan to be done..."
            self.icon = BluetoothBaseClass.IconType.IconProgress
            self.m_foundHeartRateService = True

#! [Filter HeartRate service 1]

    @Slot()
    def serviceScanDone(self):
        self.info = "Service scan done."
        self.icon = BluetoothBaseClass.IconType.IconProgress

        # Delete old service if available
        if self.m_service:
            self.m_service = None

#! [Filter HeartRate service 2]
        # If heartRateService found, create new service
        if self.m_foundHeartRateService:
            self.m_service = self.m_control.createServiceObject(
                QBluetoothUuid(QBluetoothUuid.ServiceClassUuid.HeartRate), self)

        if self.m_service:
            self.m_service.stateChanged.connect(self.serviceStateChanged)
            self.m_service.characteristicChanged.connect(self.updateHeartRateValue)
            self.m_service.descriptorWritten.connect(self.confirmedDescriptorWrite)
            self.m_service.discoverDetails()
        else:
            self.error = "Heart Rate Service not found."
            self.icon = BluetoothBaseClass.IconType.IconError

#! [Filter HeartRate service 2]

# Service functions
#! [Find HRM characteristic]
    @Slot(QLowEnergyService.ServiceState)
    def serviceStateChanged(self, switch):
        if switch == QLowEnergyService.RemoteServiceDiscovering:
            self.info = "Discovering services..."
            self.icon = BluetoothBaseClass.IconType.IconProgress
        elif switch == QLowEnergyService.RemoteServiceDiscovered:
            self.info = "Service discovered."
            self.icon = BluetoothBaseClass.IconType.IconBluetooth
            hrChar = self.m_service.characteristic(
                QBluetoothUuid(QBluetoothUuid.CharacteristicType.HeartRateMeasurement))
            if hrChar.isValid():
                self.m_notificationDesc = hrChar.descriptor(
                    QBluetoothUuid.DescriptorType.ClientCharacteristicConfiguration)
                if self.m_notificationDesc.isValid():
                    self.m_service.writeDescriptor(self.m_notificationDesc,
                                                   QByteArray.fromHex(b"0100"))
            else:
                self.error = "HR Data not found."
                self.icon = BluetoothBaseClass.IconType.IconError
        self.aliveChanged.emit()
#! [Find HRM characteristic]

#! [Reading value]
    @Slot(QLowEnergyCharacteristic, QByteArray)
    def updateHeartRateValue(self, c, value):
        # ignore any other characteristic change. Shouldn't really happen though
        if c.uuid() != QBluetoothUuid(QBluetoothUuid.CharacteristicType.HeartRateMeasurement):
            return

        data = value.data()
        flags = int(data[0])
        # Heart Rate
        hrvalue = 0
        if flags & 0x1:  # HR 16 bit little endian? otherwise 8 bit
            hrvalue = struct.unpack("<H", data[1:3])[0]
        else:
            hrvalue = struct.unpack("B", data[1:2])[0]

        self.addMeasurement(hrvalue)

#! [Reading value]
    @Slot()
    def updateDemoHR(self):
        randomValue = 0
        if self.m_currentValue < 30:  # Initial value
            randomValue = 55 + QRandomGenerator.global_().bounded(30)
        elif not self.m_measuring:  # Value when relax
            random = QRandomGenerator.global_().bounded(5)
            randomValue = self.m_currentValue - 2 + random
            randomValue = max(min(randomValue, 55), 75)
        else:  # Measuring
            random = QRandomGenerator.global_().bounded(10)
            randomValue = self.m_currentValue + random - 2

        self.addMeasurement(randomValue)

    @Slot(QLowEnergyCharacteristic, QByteArray)
    def confirmedDescriptorWrite(self, d, value):
        if (d.isValid() and d == self.m_notificationDesc
                and value == QByteArray.fromHex(b"0000")):
            # disabled notifications . assume disconnect intent
            self.m_control.disconnectFromDevice()
            self.m_service = None

    @Slot()
    def disconnectService(self):
        self.m_foundHeartRateService = False

        # disable notifications
        if (self.m_notificationDesc.isValid() and self.m_service
                and self.m_notificationDesc.value() == QByteArray.fromHex(b"0100")):
            self.m_service.writeDescriptor(self.m_notificationDesc,
                                           QByteArray.fromHex(b"0000"))
        else:
            if self.m_control:
                self.m_control.disconnectFromDevice()
            self.m_service = None

    @Property(bool, notify=measuringChanged)
    def measuring(self):
        return self.m_measuring

    @Property(bool, notify=aliveChanged)
    def alive(self):
        if simulator():
            return True
        if self.m_service:
            return self.m_service.state() == QLowEnergyService.RemoteServiceDiscovered
        return False

    @Property(int, notify=statsChanged)
    def hr(self):
        return self.m_currentValue

    @Property(int, notify=statsChanged)
    def time(self):
        return self.m_start.secsTo(self.m_stop)

    @Property(int, notify=statsChanged)
    def maxHR(self):
        return self.m_max

    @Property(int, notify=statsChanged)
    def minHR(self):
        return self.m_min

    @Property(float, notify=statsChanged)
    def average(self):
        return self.m_avg

    @Property(float, notify=statsChanged)
    def calories(self):
        return self.m_calories

    def addMeasurement(self, value):
        self.m_currentValue = value

        # If measuring and value is appropriate
        if self.m_measuring and value > 30 and value < 250:
            self.m_stop = QDateTime.currentDateTime()
            self.m_measurements.append(value)

            self.m_min = value if self.m_min == 0 else min(value, self.m_min)
            self.m_max = max(value, self.m_max)
            self.m_sum += value
            self.m_avg = float(self.m_sum) / len(self.m_measurements)
            self.m_calories = ((-55.0969 + (0.6309 * self.m_avg) + (0.1988 * 94)
                                + (0.2017 * 24)) / 4.184) * 60 * self.time / 3600

        self.statsChanged.emit()

    addressType = Property(int, addressType, setAddressType, freset=resetAddressType)
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from __future__ import annotations

import sys

from PySide6.QtCore import QObject, Property, Signal

from heartrate_global import simulator


class DeviceInfo(QObject):

    deviceChanged = Signal()

    def __init__(self, device):
        super().__init__()
        self.m_device = device

    def device(self):
        return self.m_device

    def setDevice(self, device):
        self.m_device = device
        self.deviceChanged.emit()

    @Property(str, notify=deviceChanged)
    def deviceName(self):
        if simulator():
            return "Demo BT device"
        return self.m_device.name()

    @Property(str, notify=deviceChanged)
    def deviceAddress(self):
        if simulator():
            return "00:11:22:33:44:55"
        if sys.platform == "Darwin":  # workaround for Core Bluetooth:
            return self.m_device.deviceUuid().toString()
        return self.m_device.address().toString()
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from __future__ import annotations
import os
import sys

_simulator = False


def simulator():
    return _simulator


def set_simulator(s):
    global _simulator
    _simulator = s


is_android = os.environ.get('ANDROID_ARGUMENT')


def error_not_nuitka():
    """Errors and exits for macOS if run in interpreted mode.
    """
    is_nuitka = "__compiled__" in globals()
    if not is_nuitka and sys.platform == "darwin":
        print("This example does not work on macOS when Python is run in interpreted mode."
              "For this example to work on macOS, package the example using pyside6-deploy"
              "For more information, read `Notes for Developer` in the documentation")
        sys.exit(0)
module HeartRateGame
App 1.0 App.qml
BluetoothAlarmDialog 1.0 BluetoothAlarmDialog.qml
BottomLine 1.0 BottomLine.qml
Connect 1.0 Connect.qml
GameButton 1.0 GameButton.qml
GamePage 1.0 GamePage.qml
singleton GameSettings 1.0 GameSettings.qml
Measure 1.0 Measure.qml
SplashScreen 1.0 SplashScreen.qml
Stats 1.0 Stats.qml
StatsLabel 1.0 StatsLabel.qml
TitleBar 1.0 TitleBar.qml
Main 1.0 Main.qml
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Window
import HeartRateGame

Window {
    id: wroot
    visible: true
    width: 720 * .7
    height: 1240 * .7
    title: qsTr("HeartRateGame")
    color: GameSettings.backgroundColor

    required property ConnectionHandler connectionHandler
    required property DeviceFinder deviceFinder
    required property DeviceHandler deviceHandler

    Component.onCompleted: {
        GameSettings.wWidth = Qt.binding(function () {
            return width
        })
        GameSettings.wHeight = Qt.binding(function () {
            return height
        })
    }

    Loader {
        id: splashLoader
        anchors.fill: parent
        asynchronous: false
        visible: true

        sourceComponent: SplashScreen {
            appIsReady: appLoader.status === Loader.Ready
            onReadyChanged: {
                if (ready) {
                    appLoader.visible = true
                    splashLoader.visible = false
                    splashLoader.active = false
                }
            }
        }

        onStatusChanged: {
            if (status === Loader.Ready)
                appLoader.active = true
        }
    }

    Loader {
        id: appLoader
        anchors.fill: parent
        active: false
        asynchronous: true
        visible: false

        sourceComponent: App {
            connectionHandler: wroot.connectionHandler
            deviceFinder: wroot.deviceFinder
            deviceHandler: wroot.deviceHandler
        }

        onStatusChanged: {
            if (status === Loader.Error)
                Qt.quit()
        }
    }
}
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

import QtQuick
import QtQuick.Layouts
import HeartRateGame

Item {
    id: app

    required property ConnectionHandler connectionHandler
    required property DeviceFinder deviceFinder
    required property DeviceHandler deviceHandler

    anchors.fill: parent
    opacity: 0.0

    Behavior on opacity {
        NumberAnimation {
            duration: 500
        }
    }

    property int __currentIndex: 0

    TitleBar {
        id: titleBar
        anchors.top: parent.top
        anchors.left: parent.left
        anchors.right: parent.right
        currentIndex: app.__currentIndex

        onTitleClicked: (index) => {
            if (index < app.__currentIndex)
                app.__currentIndex = index
        }
    }

    StackLayout {
        id: pageStack
        anchors.left: parent.left
        anchors.right: parent.right
        anchors.top: titleBar.bottom
        anchors.bottom: parent.bottom
        currentIndex: app.__currentIndex

        Connect {
            connectionHandler: app.connectionHandler
            deviceFinder: app.deviceFinder
            deviceHandler: app.deviceHandler

            onShowMeasurePage: app.__currentIndex = 1
        }
        Measure {
            id: measurePage
            deviceHandler: app.deviceHandler

            onShowStatsPage: app.__currentIndex = 2
        }
        Stats {
            deviceHandler: app.deviceHandler
        }

        onCurrentIndexChanged: {
            if (currentIndex === 0)
                measurePage.close()
        }
    }

    BluetoothAlarmDialog {
        id: btAlarmDialog
        anchors.fill: parent
        visible: !app.connectionHandler.alive || permissionError
        permissionError: !app.connectionHandler.hasPermission
    }

    Keys.onReleased: (event) => {
        switch (event.key) {
        case Qt.Key_Escape:
        case Qt.Key_Back:
        {
            if (app.__currentIndex > 0) {
                app.__currentIndex = app.__currentIndex - 1
                event.accepted = true
            } else {
                Qt.quit()
            }
            break
        }
        default:
            break
        }
    }

    Component.onCompleted: {
        forceActiveFocus()
        app.opacity = 1.0
    }
}
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

import QtQuick

Item {
    id: root

    property bool permissionError: false

    anchors.fill: parent

    Rectangle {
        anchors.fill: parent
        color: "black"
        opacity: 0.9
    }

    MouseArea {
        id: eventEater
    }

    Rectangle {
        id: dialogFrame

        anchors.centerIn: parent
        width: parent.width * 0.8
        height: parent.height * 0.6
        border.color: "#454545"
        color: GameSettings.backgroundColor
        radius: width * 0.05

        Item {
            id: dialogContainer
            anchors.fill: parent
            anchors.margins: parent.width*0.05

            Image {
                id: offOnImage
                anchors.left: quitButton.left
                anchors.right: quitButton.right
                anchors.top: parent.top
                height: GameSettings.heightForWidth(width, sourceSize)
                source: "images/bt_off_to_on.png"
            }

            Text {
                anchors.left: parent.left
                anchors.right: parent.right
                anchors.top: offOnImage.bottom
                anchors.bottom: quitButton.top
                horizontalAlignment: Text.AlignHCenter
                verticalAlignment: Text.AlignVCenter
                wrapMode: Text.WordWrap
                font.pixelSize: GameSettings.smallFontSize
                color: GameSettings.textColor
                text: root.permissionError
                      ? qsTr("Bluetooth permissions are not granted. Please grant the permissions in the system settings.")
                      : qsTr("This application cannot be used without Bluetooth. Please switch Bluetooth ON to continue.")
            }

            GameButton {
                id: quitButton
                anchors.bottom: parent.bottom
                anchors.horizontalCenter: parent.horizontalCenter
                width: dialogContainer.width * 0.6
                height: GameSettings.buttonHeight
                onClicked: Qt.quit()

                Text {
                    anchors.centerIn: parent
                    color: GameSettings.textColor
                    font.pixelSize: GameSettings.microFontSize
                    text: qsTr("QUIT")
                }
            }
        }
    }
}
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

import QtQuick

Rectangle {
    anchors.horizontalCenter: parent.horizontalCenter
    anchors.bottom: parent.bottom
    width: parent.width
    height: parent.height * 0.05
}
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

pragma ComponentBehavior: Bound
import QtQuick
import HeartRateGame

GamePage {
    id: connectPage

    required property ConnectionHandler connectionHandler
    required property DeviceFinder deviceFinder
    required property DeviceHandler deviceHandler

    signal showMeasurePage

    errorMessage: deviceFinder.error
    infoMessage: deviceFinder.info
    iconType: deviceFinder.icon

    Text {
        id: viewCaption
        anchors {
            top: parent.top
            topMargin: GameSettings.fieldMargin + connectPage.messageHeight
            horizontalCenter: parent.horizontalCenter
        }
        width: parent.width - GameSettings.fieldMargin * 2
        height: GameSettings.fieldHeight
        horizontalAlignment: Text.AlignLeft
        verticalAlignment: Text.AlignVCenter
        color: GameSettings.textColor
        font.pixelSize: GameSettings.smallFontSize
        text: qsTr("Found Devices")
    }

    Rectangle {
        id: viewContainer
        anchors.top: viewCaption.bottom
        // only BlueZ platform has address type selection
        anchors.bottom: connectPage.connectionHandler.requiresAddressType ? addressTypeButton.top
                                                                          : searchButton.top
        anchors.bottomMargin: GameSettings.fieldMargin
        anchors.horizontalCenter: parent.horizontalCenter
        width: parent.width - GameSettings.fieldMargin * 2
        color: GameSettings.viewColor
        radius: GameSettings.buttonRadius

        ListView {
            id: devices
            anchors.fill: parent
            model: connectPage.deviceFinder.devices
            clip: true

            delegate: Rectangle {
                id: box

                required property int index
                required property var modelData

                height: GameSettings.fieldHeight * 1.2
                width: devices.width
                color: index % 2 === 0 ? GameSettings.delegate1Color : GameSettings.delegate2Color

                MouseArea {
                    anchors.fill: parent
                    onClicked: {
                        connectPage.deviceFinder.connectToService(box.modelData.deviceAddress)
                        connectPage.showMeasurePage()
                    }
                }

                Text {
                    id: device
                    font.pixelSize: GameSettings.microFontSize
                    text: box.modelData.deviceName
                    anchors.top: parent.top
                    anchors.topMargin: parent.height * 0.15
                    anchors.leftMargin: parent.height * 0.15
                    anchors.left: parent.left
                    color: GameSettings.textColor
                }

                Text {
                    id: deviceAddress
                    font.pixelSize: GameSettings.microFontSize
                    text: box.modelData.deviceAddress
                    anchors.bottom: parent.bottom
                    anchors.bottomMargin: parent.height * 0.15
                    anchors.rightMargin: parent.height * 0.15
                    anchors.right: parent.right
                    color: Qt.darker(GameSettings.textColor)
                }
            }
        }
    }

    GameButton {
        id: addressTypeButton
        anchors.horizontalCenter: parent.horizontalCenter
        anchors.bottom: searchButton.top
        anchors.bottomMargin: GameSettings.fieldMargin * 0.5
        width: viewContainer.width
        height: GameSettings.fieldHeight
        visible: connectPage.connectionHandler.requiresAddressType // only required on BlueZ
        state: "public"
        onClicked: state === "public" ? state = "random" : state = "public"

        states: [
            State {
                name: "public"
                PropertyChanges {
                    addressTypeText.text: qsTr("PUBLIC ADDRESS")
                }
                PropertyChanges {
                    connectPage.deviceHandler.addressType: DeviceHandler.PublicAddress
                }
            },
            State {
                name: "random"
                PropertyChanges {
                    addressTypeText.text: qsTr("RANDOM ADDRESS")
                }
                PropertyChanges {
                    connectPage.deviceHandler.addressType: DeviceHandler.RandomAddress
                }
            }
        ]

        Text {
            id: addressTypeText
            anchors.centerIn: parent
            font.pixelSize: GameSettings.microFontSize
            color: GameSettings.textDarkColor
        }
    }

    GameButton {
        id: searchButton
        anchors.horizontalCenter: parent.horizontalCenter
        anchors.bottom: parent.bottom
        anchors.bottomMargin: GameSettings.fieldMargin
        width: viewContainer.width
        height: GameSettings.fieldHeight
        enabled: !connectPage.deviceFinder.scanning
        onClicked: connectPage.deviceFinder.startSearch()

        Text {
            anchors.centerIn: parent
            font.pixelSize: GameSettings.microFontSize
            text: qsTr("START SEARCH")
            color: GameSettings.textDarkColor
        }
    }
}
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

import QtQuick

Rectangle {
    id: button
    color: baseColor
    onEnabledChanged: checkColor()
    radius: GameSettings.buttonRadius

    property color baseColor: GameSettings.buttonColor
    property color pressedColor: GameSettings.buttonPressedColor
    property color disabledColor: GameSettings.disabledButtonColor

    signal clicked

    function checkColor() {
        if (!button.enabled) {
            button.color = disabledColor
        } else {
            if (mouseArea.containsPress)
                button.color = pressedColor
            else
                button.color = baseColor
        }
    }

    MouseArea {
        id: mouseArea
        anchors.fill: parent
        onPressed: button.checkColor()
        onReleased: button.checkColor()
        onClicked: {
            button.checkColor()
            button.clicked()
        }
    }
}
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

import QtQuick

Item {
    id: page

    property string errorMessage: ""
    property string infoMessage: ""
    property real messageHeight: msg.height
    property bool hasError: errorMessage != ""
    property bool hasInfo: infoMessage != ""
    property int iconType: BluetoothBaseClass.IconNone

    function iconTypeToName(icon: int) : string {
        switch (icon) {
        case BluetoothBaseClass.IconNone: return ""
        case BluetoothBaseClass.IconBluetooth: return "images/bluetooth.svg"
        case BluetoothBaseClass.IconError: return "images/alert.svg"
        case BluetoothBaseClass.IconProgress: return "images/progress.svg"
        case BluetoothBaseClass.IconSearch: return "images/search.svg"
        }
    }

    Rectangle {
        id: msg
        anchors {
            top: parent.top
            left: parent.left
            right: parent.right
            topMargin: GameSettings.fieldMargin * 0.5
            leftMargin: GameSettings.fieldMargin
            rightMargin: GameSettings.fieldMargin
        }
        height: GameSettings.fieldHeight
        radius: GameSettings.buttonRadius
        color: page.hasError ? GameSettings.errorColor : "transparent"
        visible: page.hasError || page.hasInfo
        border {
            width: 1
            color: page.hasError ? GameSettings.errorColor : GameSettings.infoColor
        }

        Image {
            id: icon
            readonly property int imgSize: GameSettings.fieldHeight * 0.5
            anchors {
                left: parent.left
                leftMargin: GameSettings.fieldMargin * 0.5
                verticalCenter: parent.verticalCenter
            }
            visible: source.toString() !== ""
            source: page.iconTypeToName(page.iconType)
            sourceSize.width: imgSize
            sourceSize.height: imgSize
            fillMode: Image.PreserveAspectFit
        }

        Text {
            id: error
            anchors {
                fill: parent
                leftMargin: GameSettings.fieldMargin + icon.width
                rightMargin: GameSettings.fieldMargin + icon.width
            }
            horizontalAlignment: Text.AlignHCenter
            verticalAlignment: Text.AlignVCenter
            minimumPixelSize: 5
            font.pixelSize: GameSettings.microFontSize
            fontSizeMode: Text.Fit
            color: page.hasError ? GameSettings.textColor : GameSettings.infoColor
            text: page.hasError ? page.errorMessage : page.infoMessage
        }
    }
}
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

pragma Singleton
import QtQuick

QtObject {
    property int wHeight
    property int wWidth

    // Colors
    readonly property color lightGreenColor: "#80ebb6"
    readonly property color backgroundColor: "#2c3038"
    readonly property color buttonColor: "#2cde85"
    readonly property color buttonPressedColor: lightGreenColor
    readonly property color disabledButtonColor: "#808080"
    readonly property color viewColor: "#262626"
    readonly property color delegate1Color: "#262626"
    readonly property color delegate2Color: "#404040"
    readonly property color textColor: "#ffffff"
    readonly property color textDarkColor: "#0d0d0d"
    readonly property color textInfoColor: lightGreenColor
    readonly property color sliderColor: "#00414a"
    readonly property color sliderBorderColor: lightGreenColor
    readonly property color sliderTextColor: lightGreenColor
    readonly property color errorColor: "#ba3f62"
    readonly property color infoColor: lightGreenColor
    readonly property color titleColor: "#202227"
    readonly property color selectedTitleColor: "#19545c"
    readonly property color hoverTitleColor: Qt.rgba(selectedTitleColor.r,
                                                     selectedTitleColor.g,
                                                     selectedTitleColor.b,
                                                     0.25)
    readonly property color bottomLineColor: "#e6e6e6"
    readonly property color heartRateColor: "#f80067"

    // All the fonts are given for the window of certain size.
    // Resizing the window changes all the fonts accordingly
    readonly property int defaultSize: 500
    readonly property real fontScaleFactor: Math.min(wWidth, wHeight) / defaultSize

    // Font sizes
    readonly property real microFontSize: 16 * fontScaleFactor
    readonly property real tinyFontSize: 20 * fontScaleFactor
    readonly property real smallFontSize: 24 * fontScaleFactor
    readonly property real mediumFontSize: 32 * fontScaleFactor
    readonly property real bigFontSize: 36 * fontScaleFactor
    readonly property real largeFontSize: 54 * fontScaleFactor
    readonly property real hugeFontSize: 128 * fontScaleFactor

    // Some other values
    property real fieldHeight: wHeight * 0.08
    property real fieldMargin: fieldHeight * 0.5
    property real buttonHeight: wHeight * 0.08
    property real buttonRadius: buttonHeight * 0.1

    // Some help functions
    function heightForWidth(w, ss) {
        return w / ss.width * ss.height
    }
}
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

import QtQuick
import HeartRateGame

GamePage {
    id: measurePage

    required property DeviceHandler deviceHandler

    errorMessage: deviceHandler.error
    infoMessage: deviceHandler.info
    iconType: deviceHandler.icon

    property real __timeCounter: 0
    property real __maxTimeCount: 60

    readonly property string relaxText: qsTr("Relax!")
    readonly property string startText: qsTr("When you are ready,\npress Start.")
    readonly property string instructionText: qsTr("You have %1s time to increase heart\nrate as much as possible.").arg(__maxTimeCount)
    readonly property string goodLuckText: qsTr("Good luck!")

    signal showStatsPage

    function close() {
        deviceHandler.stopMeasurement()
        deviceHandler.disconnectService()
    }

    function start() {
        if (!deviceHandler.measuring) {
            __timeCounter = 0
            deviceHandler.startMeasurement()
        }
    }

    function stop() {
        if (deviceHandler.measuring)
            deviceHandler.stopMeasurement()

        measurePage.showStatsPage()
    }

    Timer {
        id: measureTimer
        interval: 1000
        running: measurePage.deviceHandler.measuring
        repeat: true
        onTriggered: {
            measurePage.__timeCounter++
            if (measurePage.__timeCounter >= measurePage.__maxTimeCount)
                measurePage.stop()
        }
    }

    Column {
        anchors.centerIn: parent
        spacing: GameSettings.fieldHeight * 0.5

        Rectangle {
            id: circle

            readonly property bool hintVisible: !measurePage.deviceHandler.measuring
            readonly property real innerSpacing: Math.min(width * 0.05, 25)

            anchors.horizontalCenter: parent.horizontalCenter
            width: Math.min(measurePage.width, measurePage.height - GameSettings.fieldHeight * 4)
                   - 2 * GameSettings.fieldMargin
            height: width
            radius: width * 0.5
            color: GameSettings.viewColor

            Text {
                id: relaxTextBox
                anchors {
                    bottom: startTextBox.top
                    bottomMargin: parent.innerSpacing
                    horizontalCenter: parent.horizontalCenter
                }
                width: parent.width * 0.6
                height: parent.height * 0.1
                horizontalAlignment: Text.AlignHCenter
                verticalAlignment: Text.AlignVCenter
                text: measurePage.relaxText
                visible: circle.hintVisible
                color: GameSettings.textColor
                fontSizeMode: Text.Fit
                font.pixelSize: GameSettings.smallFontSize
                font.bold: true
            }

            Text {
                id: startTextBox
                anchors {
                    bottom: heart.top
                    bottomMargin: parent.innerSpacing
                    horizontalCenter: parent.horizontalCenter
                }
                width: parent.width * 0.8
                height: parent.height * 0.15
                horizontalAlignment: Text.AlignHCenter
                verticalAlignment: Text.AlignVCenter
                text: measurePage.startText
                visible: circle.hintVisible
                color: GameSettings.textColor
                fontSizeMode: Text.Fit
                font.pixelSize: GameSettings.tinyFontSize
            }

            Text {
                id: measureTextBox
                anchors {
                    bottom: heart.top
                    horizontalCenter: parent.horizontalCenter
                }
                width: parent.width * 0.7
                height: parent.height * 0.35
                horizontalAlignment: Text.AlignHCenter
                verticalAlignment: Text.AlignVCenter
                text: measurePage.deviceHandler.hr
                visible: measurePage.deviceHandler.measuring
                color: GameSettings.heartRateColor
                fontSizeMode: Text.Fit
                font.pixelSize: GameSettings.hugeFontSize
                font.bold: true
            }

            Image {
                id: heart
                anchors.centerIn: circle
                width: parent.width * 0.2
                height: width
                fillMode: Image.PreserveAspectFit
                source: "images/heart.png"
                smooth: true
                antialiasing: true

                SequentialAnimation {
                    id: heartAnim
                    running: measurePage.deviceHandler.measuring
                    loops: Animation.Infinite
                    alwaysRunToEnd: true
                    PropertyAnimation {
                        target: heart
                        property: "scale"
                        to: 1.4
                        duration: 500
                        easing.type: Easing.InQuad
                    }
                    PropertyAnimation {
                        target: heart
                        property: "scale"
                        to: 1.0
                        duration: 500
                        easing.type: Easing.OutQuad
                    }
                }
            }

            Text {
                id: instructionTextBox
                anchors {
                    top: heart.bottom
                    topMargin: parent.innerSpacing
                    horizontalCenter: parent.horizontalCenter
                }
                width: parent.width * 0.8
                height: parent.height * 0.15
                horizontalAlignment: Text.AlignHCenter
                verticalAlignment: Text.AlignVCenter
                text: measurePage.instructionText
                visible: circle.hintVisible
                color: GameSettings.textColor
                fontSizeMode: Text.Fit
                font.pixelSize: GameSettings.tinyFontSize
            }

            Text {
                id: goodLuckBox
                anchors {
                    top: instructionTextBox.bottom
                    topMargin: parent.innerSpacing
                    horizontalCenter: parent.horizontalCenter
                }
                width: parent.width * 0.6
                height: parent.height * 0.1
                horizontalAlignment: Text.AlignHCenter
                verticalAlignment: Text.AlignVCenter
                text: measurePage.goodLuckText
                visible: circle.hintVisible
                color: GameSettings.textColor
                fontSizeMode: Text.Fit
                font.pixelSize: GameSettings.smallFontSize
                font.bold: true
            }

            Item {
                id: minMaxContainer
                anchors.horizontalCenter: parent.horizontalCenter
                width: parent.width * 0.7
                height: parent.height * 0.15
                anchors.bottom: parent.bottom
                anchors.bottomMargin: parent.height * 0.16
                visible: measurePage.deviceHandler.measuring

                Text {
                    anchors.left: parent.left
                    anchors.verticalCenter: parent.verticalCenter
                    width: parent.width * 0.35
                    horizontalAlignment: Text.AlignLeft
                    verticalAlignment: Text.AlignVCenter
                    text: measurePage.deviceHandler.minHR
                    color: GameSettings.textColor
                    fontSizeMode: Text.Fit
                    font.pixelSize: GameSettings.largeFontSize

                    Text {
                        anchors.left: parent.left
                        anchors.bottom: parent.top
                        horizontalAlignment: Text.AlignLeft
                        verticalAlignment: Text.AlignVCenter
                        width: parent.width
                        fontSizeMode: Text.Fit
                        font.pixelSize: GameSettings.mediumFontSize
                        color: parent.color
                        text: "MIN"
                    }
                }

                Text {
                    anchors.right: parent.right
                    anchors.verticalCenter: parent.verticalCenter
                    horizontalAlignment: Text.AlignRight
                    verticalAlignment: Text.AlignVCenter
                    width: parent.width * 0.35
                    text: measurePage.deviceHandler.maxHR
                    color: GameSettings.textColor
                    fontSizeMode: Text.Fit
                    font.pixelSize: GameSettings.largeFontSize

                    Text {
                        anchors.right: parent.right
                        anchors.bottom: parent.top
                        horizontalAlignment: Text.AlignRight
                        verticalAlignment: Text.AlignVCenter
                        width: parent.width
                        fontSizeMode: Text.Fit
                        font.pixelSize: GameSettings.mediumFontSize
                        color: parent.color
                        text: "MAX"
                    }
                }
            }
        }

        Rectangle {
            id: timeSlider
            color: GameSettings.viewColor
            anchors.horizontalCenter: parent.horizontalCenter
            width: circle.width
            height: GameSettings.fieldHeight
            radius: GameSettings.buttonRadius
            border {
                width: 1
                color: GameSettings.sliderBorderColor
            }

            Rectangle {
                anchors {
                    top: parent.top
                    topMargin: parent.border.width
                    left: parent.left
                    leftMargin: parent.border.width
                }
                height: parent.height - 2 * parent.border.width
                width: Math.min(1.0, measurePage.__timeCounter / measurePage.__maxTimeCount)
                       * (parent.width - 2 * parent.border.width)
                radius: parent.radius
                color: GameSettings.sliderColor
            }

            Image {
                readonly property int imgSize: GameSettings.fieldHeight * 0.5
                anchors {
                    verticalCenter: parent.verticalCenter
                    left: parent.left
                    leftMargin: GameSettings.fieldMargin * 0.5
                }
                source: "images/clock.svg"
                sourceSize.width: imgSize
                sourceSize.height: imgSize
                fillMode: Image.PreserveAspectFit
            }

            Text {
                anchors.centerIn: parent
                color: GameSettings.sliderTextColor
                text: (measurePage.__maxTimeCount - measurePage.__timeCounter).toFixed(0) + " s"
                font.pixelSize: GameSettings.smallFontSize
            }
        }
    }

    GameButton {
        id: startButton
        anchors.horizontalCenter: parent.horizontalCenter
        anchors.bottom: parent.bottom
        anchors.bottomMargin: GameSettings.fieldMargin
        width: circle.width
        height: GameSettings.fieldHeight
        enabled: measurePage.deviceHandler.alive && !measurePage.deviceHandler.measuring
                 && measurePage.errorMessage === ""
        radius: GameSettings.buttonRadius

        onClicked: measurePage.start()

        Text {
            anchors.centerIn: parent
            font.pixelSize: GameSettings.microFontSize
            text: qsTr("START")
            color: GameSettings.textDarkColor
        }
    }
}
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

import QtQuick
import HeartRateGame

Item {
    id: root

    property bool appIsReady: false
    property bool splashIsReady: false
    property bool ready: appIsReady && splashIsReady

    anchors.fill: parent

    Image {
        anchors.centerIn: parent
        width: Math.min(parent.height, parent.width) * 0.6
        height: GameSettings.heightForWidth(width, sourceSize)
        source: "images/logo.png"
    }

    Timer {
        id: splashTimer
        interval: 1000
        onTriggered: root.splashIsReady = true
    }

    Component.onCompleted: splashTimer.start()
}
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

import QtQuick
import HeartRateGame

GamePage {
    id: statsPage

    required property DeviceHandler deviceHandler

    Column {
        anchors.centerIn: parent
        width: parent.width

        Rectangle {
            id: resultRect
            anchors.horizontalCenter: parent.horizontalCenter
            width: height
            height: statsPage.height / 2 - GameSettings.fieldHeight
            radius: height / 2
            color: GameSettings.viewColor

            Column {
                anchors.centerIn: parent

                Text {
                    id: resultCaption
                    anchors.horizontalCenter: parent.horizontalCenter
                    width: resultRect.width * 0.8
                    height: resultRect.height * 0.15
                    horizontalAlignment: Text.AlignHCenter
                    fontSizeMode: Text.Fit
                    font.pixelSize: GameSettings.bigFontSize
                    color: GameSettings.textColor
                    text: qsTr("RESULT")
                }

                Text {
                    id: resultValue
                    anchors.horizontalCenter: parent.horizontalCenter
                    width: resultRect.width * 0.8
                    height: resultRect.height * 0.4
                    horizontalAlignment: Text.AlignHCenter
                    fontSizeMode: Text.Fit
                    font.pixelSize: GameSettings.hugeFontSize
                    font.bold: true
                    color: GameSettings.heartRateColor
                    text: (statsPage.deviceHandler.maxHR - statsPage.deviceHandler.minHR).toFixed(0)
                }
            }
        }


        Item {
            height: GameSettings.fieldHeight
            width: 1
        }

        StatsLabel {
            title: qsTr("MIN")
            value: statsPage.deviceHandler.minHR.toFixed(0)
        }

        StatsLabel {
            title: qsTr("MAX")
            value: statsPage.deviceHandler.maxHR.toFixed(0)
        }

        StatsLabel {
            title: qsTr("AVG")
            value: statsPage.deviceHandler.average.toFixed(1)
        }

        StatsLabel {
            title: qsTr("CALORIES")
            value: statsPage.deviceHandler.calories.toFixed(3)
        }
    }
}
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

import QtQuick

Item {
    height: GameSettings.fieldHeight
    width: parent.width

    property alias title: leftText.text
    property alias value: rightText.text

    Text {
        id: leftText
        anchors.left: parent.left
        height: parent.height
        width: parent.width * 0.45
        horizontalAlignment: Text.AlignRight
        verticalAlignment: Text.AlignVCenter
        font.pixelSize: GameSettings.mediumFontSize
        color: GameSettings.textColor
    }

    Text {
        id: rightText
        anchors.right: parent.right
        height: parent.height
        width: parent.width * 0.45
        horizontalAlignment: Text.AlignLeft
        verticalAlignment: Text.AlignVCenter
        font.pixelSize: GameSettings.mediumFontSize
        color: GameSettings.textColor
    }
}
// Copyright (C) 2022 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

pragma ComponentBehavior: Bound
import QtQuick

Rectangle {
    id: titleBar

    property var __titles: ["CONNECT", "MEASURE", "STATS"]
    property int currentIndex: 0

    signal titleClicked(int index)

    height: GameSettings.fieldHeight
    color: GameSettings.titleColor

    Rectangle {
        anchors.bottom: parent.bottom
        width: parent.width / 3
        height: parent.height
        x: titleBar.currentIndex * width
        color: GameSettings.selectedTitleColor

        BottomLine {
            color: GameSettings.bottomLineColor
        }

        Behavior on x {
            NumberAnimation {
                duration: 200
            }
        }
    }

    Repeater {
        model: 3
        Rectangle {
            id: caption
            required property int index
            property bool hoveredOrPressed: mouseArea.pressed || mouseArea.containsMouse
            width: titleBar.width / 3
            height: titleBar.height
            x: index * width
            color: (titleBar.currentIndex !== index) && hoveredOrPressed
                   ? GameSettings.hoverTitleColor : "transparent"
            Text {
                anchors.fill: parent
                horizontalAlignment: Text.AlignHCenter
                verticalAlignment: Text.AlignVCenter
                text: titleBar.__titles[caption.index]
                font.pixelSize: GameSettings.microFontSize
                color: GameSettings.textColor
            }
            MouseArea {
                id: mouseArea
                anchors.fill: parent
                hoverEnabled: true
                onClicked: titleBar.titleClicked(caption.index)
            }
        }
    }
}