diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml new file mode 100644 index 0000000..126af2b --- /dev/null +++ b/android/AndroidManifest.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..443a800 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,77 @@ +buildscript { + repositories { + google() + jcenter() + } + + dependencies { + classpath 'com.android.tools.build:gradle:3.6.0' + } +} + +repositories { + google() + jcenter() +} + +apply plugin: 'com.android.application' + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) +} + +android { + /******************************************************* + * The following variables: + * - androidBuildToolsVersion, + * - androidCompileSdkVersion + * - qt5AndroidDir - holds the path to qt android files + * needed to build any Qt application + * on Android. + * + * are defined in gradle.properties file. This file is + * updated by QtCreator and androiddeployqt tools. + * Changing them manually might break the compilation! + *******************************************************/ + + compileSdkVersion androidCompileSdkVersion.toInteger() + + buildToolsVersion '28.0.3' + + sourceSets { + main { + manifest.srcFile 'AndroidManifest.xml' + java.srcDirs = [qt5AndroidDir + '/src', 'src', 'java'] + aidl.srcDirs = [qt5AndroidDir + '/src', 'src', 'aidl'] + res.srcDirs = [qt5AndroidDir + '/res', 'res'] + resources.srcDirs = ['resources'] + renderscript.srcDirs = ['src'] + assets.srcDirs = ['assets'] + jniLibs.srcDirs = ['libs'] + } + } + + tasks.withType(JavaCompile) { + options.incremental = true + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + lintOptions { + abortOnError false + } + + // Do not compress Qt binary resources file + aaptOptions { + noCompress 'rcc' + } + + defaultConfig { + resConfig "en" + minSdkVersion = qtMinSdkVersion + targetSdkVersion = qtTargetSdkVersion + } +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..fded106 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,11 @@ +# Project-wide Gradle settings. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m + +# Gradle caching allows reusing the build artifacts from a previous +# build with the same inputs. However, over time, the cache size will +# grow. Uncomment the following line to enable it. +#org.gradle.caching=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f6b961f Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..5028f28 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.4-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..cccdd3d --- /dev/null +++ b/android/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..f955316 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/res/drawable-hdpi/icon.png b/android/res/drawable-hdpi/icon.png new file mode 100644 index 0000000..4265645 Binary files /dev/null and b/android/res/drawable-hdpi/icon.png differ diff --git a/android/res/drawable-ldpi/icon.png b/android/res/drawable-ldpi/icon.png new file mode 100644 index 0000000..1c48adc Binary files /dev/null and b/android/res/drawable-ldpi/icon.png differ diff --git a/android/res/drawable-mdpi/icon.png b/android/res/drawable-mdpi/icon.png new file mode 100644 index 0000000..f96222a Binary files /dev/null and b/android/res/drawable-mdpi/icon.png differ diff --git a/android/res/drawable-xhdpi/icon.png b/android/res/drawable-xhdpi/icon.png new file mode 100644 index 0000000..f6a9ed1 Binary files /dev/null and b/android/res/drawable-xhdpi/icon.png differ diff --git a/android/res/drawable-xxhdpi/icon.png b/android/res/drawable-xxhdpi/icon.png new file mode 100644 index 0000000..655eb59 Binary files /dev/null and b/android/res/drawable-xxhdpi/icon.png differ diff --git a/android/res/drawable-xxxhdpi/icon.png b/android/res/drawable-xxxhdpi/icon.png new file mode 100644 index 0000000..1124fb7 Binary files /dev/null and b/android/res/drawable-xxxhdpi/icon.png differ diff --git a/android/res/values/libs.xml b/android/res/values/libs.xml new file mode 100644 index 0000000..6b1a4a2 --- /dev/null +++ b/android/res/values/libs.xml @@ -0,0 +1,22 @@ + + + + https://download.qt.io/ministro/android/qt5/qt-5.14 + + + + + + + + + + + + + + + + + diff --git a/bluetoothbaseclass.cpp b/bluetoothbaseclass.cpp new file mode 100644 index 0000000..ad16e48 --- /dev/null +++ b/bluetoothbaseclass.cpp @@ -0,0 +1,37 @@ +#include "bluetoothbaseclass.h" + +BluetoothBaseClass::BluetoothBaseClass(QObject *parent) : QObject(parent) +{ +} + +QString BluetoothBaseClass::error() const +{ + return m_error; +} + +QString BluetoothBaseClass::info() const +{ + return m_info; +} + +void BluetoothBaseClass::setError(const QString &error) +{ + if (m_error != error) { + m_error = error; + emit errorChanged(); + } +} + +void BluetoothBaseClass::setInfo(const QString &info) +{ + if (m_info != info) { + m_info = info; + emit infoChanged(); + } +} + +void BluetoothBaseClass::clearMessages() +{ + setInfo(""); + setError(""); +} diff --git a/bluetoothbaseclass.h b/bluetoothbaseclass.h new file mode 100644 index 0000000..49568ab --- /dev/null +++ b/bluetoothbaseclass.h @@ -0,0 +1,32 @@ +#ifndef BLUETOOTHBASECLASS_H +#define BLUETOOTHBASECLASS_H + +#include + +class BluetoothBaseClass : public QObject +{ + Q_OBJECT + Q_PROPERTY(QString error READ error WRITE setError NOTIFY errorChanged) + Q_PROPERTY(QString info READ info WRITE setInfo NOTIFY infoChanged) + +public: + explicit BluetoothBaseClass(QObject *parent = nullptr); + + QString error() const; + void setError(const QString& error); + + QString info() const; + void setInfo(const QString& info); + + void clearMessages(); + +signals: + void errorChanged(); + void infoChanged(); + +private: + QString m_error; + QString m_info; +}; + +#endif // BLUETOOTHBASECLASS_H diff --git a/bobbycar-app.pro b/bobbycar-app.pro new file mode 100644 index 0000000..293814d --- /dev/null +++ b/bobbycar-app.pro @@ -0,0 +1,37 @@ +TEMPLATE = app +TARGET = bobbycar-app + +QT += qml quick bluetooth +CONFIG += c++17 + +HEADERS += \ + connectionhandler.h \ + deviceinfo.h \ + devicefinder.h \ + devicehandler.h \ + bluetoothbaseclass.h + +SOURCES += main.cpp \ + connectionhandler.cpp \ + deviceinfo.cpp \ + devicefinder.cpp \ + devicehandler.cpp \ + bluetoothbaseclass.cpp + +RESOURCES += qml.qrc \ + images.qrc + +# Additional import path used to resolve QML modules in Qt Creator's code model +QML_IMPORT_PATH = + +DISTFILES += \ + android/AndroidManifest.xml \ + android/build.gradle \ + android/gradle.properties \ + android/gradle/wrapper/gradle-wrapper.jar \ + android/gradle/wrapper/gradle-wrapper.properties \ + android/gradlew \ + android/gradlew.bat \ + android/res/values/libs.xml + +ANDROID_PACKAGE_SOURCE_DIR = $$PWD/android diff --git a/connectionhandler.cpp b/connectionhandler.cpp new file mode 100644 index 0000000..98f0888 --- /dev/null +++ b/connectionhandler.cpp @@ -0,0 +1,42 @@ +#include "connectionhandler.h" +#include +#include + +ConnectionHandler::ConnectionHandler(QObject *parent) : QObject(parent) +{ + connect(&m_localDevice, &QBluetoothLocalDevice::hostModeStateChanged, + this, &ConnectionHandler::hostModeChanged); +} + +bool ConnectionHandler::alive() const +{ +#ifdef QT_PLATFORM_UIKIT + return true; +#else + return m_localDevice.isValid() && m_localDevice.hostMode() != QBluetoothLocalDevice::HostPoweredOff; +#endif +} + +bool ConnectionHandler::requiresAddressType() const +{ +#if QT_CONFIG(bluez) + return true; +#else + return false; +#endif +} + +QString ConnectionHandler::name() const +{ + return m_localDevice.name(); +} + +QString ConnectionHandler::address() const +{ + return m_localDevice.address().toString(); +} + +void ConnectionHandler::hostModeChanged(QBluetoothLocalDevice::HostMode /*mode*/) +{ + emit deviceChanged(); +} diff --git a/connectionhandler.h b/connectionhandler.h new file mode 100644 index 0000000..9c0373f --- /dev/null +++ b/connectionhandler.h @@ -0,0 +1,33 @@ +#ifndef CONNECTIONHANDLER_H +#define CONNECTIONHANDLER_H + +#include +#include + +class ConnectionHandler : public QObject +{ + Q_PROPERTY(bool alive READ alive NOTIFY deviceChanged) + Q_PROPERTY(QString name READ name NOTIFY deviceChanged) + Q_PROPERTY(QString address READ address NOTIFY deviceChanged) + Q_PROPERTY(bool requiresAddressType READ requiresAddressType CONSTANT) + + Q_OBJECT +public: + explicit ConnectionHandler(QObject *parent = nullptr); + + bool alive() const; + bool requiresAddressType() const; + QString name() const; + QString address() const; + +signals: + void deviceChanged(); + +private slots: + void hostModeChanged(QBluetoothLocalDevice::HostMode mode); + +private: + QBluetoothLocalDevice m_localDevice; +}; + +#endif // CONNECTIONHANDLER_H diff --git a/devicefinder.cpp b/devicefinder.cpp new file mode 100644 index 0000000..3357fcb --- /dev/null +++ b/devicefinder.cpp @@ -0,0 +1,108 @@ +#include "devicefinder.h" +#include "devicehandler.h" +#include "deviceinfo.h" + +DeviceFinder::DeviceFinder(DeviceHandler *handler, QObject *parent): + BluetoothBaseClass(parent), + m_deviceHandler(handler) +{ + //! [devicediscovery-1] + m_deviceDiscoveryAgent = new QBluetoothDeviceDiscoveryAgent(this); + m_deviceDiscoveryAgent->setLowEnergyDiscoveryTimeout(5000); + + connect(m_deviceDiscoveryAgent, &QBluetoothDeviceDiscoveryAgent::deviceDiscovered, this, &DeviceFinder::addDevice); + connect(m_deviceDiscoveryAgent, static_cast(&QBluetoothDeviceDiscoveryAgent::error), + this, &DeviceFinder::scanError); + + connect(m_deviceDiscoveryAgent, &QBluetoothDeviceDiscoveryAgent::finished, this, &DeviceFinder::scanFinished); + connect(m_deviceDiscoveryAgent, &QBluetoothDeviceDiscoveryAgent::canceled, this, &DeviceFinder::scanFinished); + //! [devicediscovery-1] +} + +DeviceFinder::~DeviceFinder() +{ + qDeleteAll(m_devices); + m_devices.clear(); +} + +void DeviceFinder::startSearch() +{ + clearMessages(); + m_deviceHandler->setDevice(nullptr); + qDeleteAll(m_devices); + m_devices.clear(); + + emit devicesChanged(); + + //! [devicediscovery-2] + m_deviceDiscoveryAgent->start(QBluetoothDeviceDiscoveryAgent::LowEnergyMethod); + //! [devicediscovery-2] + + emit scanningChanged(); + setInfo(tr("Scanning for devices...")); +} + +//! [devicediscovery-3] +void DeviceFinder::addDevice(const QBluetoothDeviceInfo &device) +{ + // If device is LowEnergy-device, add it to the list + if (device.coreConfigurations() & QBluetoothDeviceInfo::LowEnergyCoreConfiguration) { + m_devices.append(new DeviceInfo(device)); + setInfo(tr("Low Energy device found. Scanning more...")); +//! [devicediscovery-3] + emit devicesChanged(); +//! [devicediscovery-4] + } + //... +} +//! [devicediscovery-4] + +void DeviceFinder::scanError(QBluetoothDeviceDiscoveryAgent::Error error) +{ + if (error == QBluetoothDeviceDiscoveryAgent::PoweredOffError) + setError(tr("The Bluetooth adaptor is powered off.")); + else if (error == QBluetoothDeviceDiscoveryAgent::InputOutputError) + setError(tr("Writing or reading from the device resulted in an error.")); + else + setError(tr("An unknown error has occurred.")); +} + +void DeviceFinder::scanFinished() +{ + if (m_devices.isEmpty()) + setError(tr("No Low Energy devices found.")); + else + setInfo(tr("Scanning done.")); + + emit scanningChanged(); + emit devicesChanged(); +} + +void DeviceFinder::connectToService(const QString &address) +{ + m_deviceDiscoveryAgent->stop(); + + DeviceInfo *currentDevice = nullptr; + for (QObject *entry : qAsConst(m_devices)) { + auto device = qobject_cast(entry); + if (device && device->getAddress() == address ) { + currentDevice = device; + break; + } + } + + if (currentDevice) + m_deviceHandler->setDevice(currentDevice); + + clearMessages(); +} + +bool DeviceFinder::scanning() const +{ + return m_deviceDiscoveryAgent->isActive(); +} + +QVariant DeviceFinder::devices() +{ + return QVariant::fromValue(m_devices); +} diff --git a/devicefinder.h b/devicefinder.h new file mode 100644 index 0000000..2d6ecf6 --- /dev/null +++ b/devicefinder.h @@ -0,0 +1,48 @@ +#ifndef DEVICEFINDER_H +#define DEVICEFINDER_H + +#include "bluetoothbaseclass.h" + +#include +#include +#include +#include + + +class DeviceInfo; +class DeviceHandler; + +class DeviceFinder: public BluetoothBaseClass +{ + Q_OBJECT + + Q_PROPERTY(bool scanning READ scanning NOTIFY scanningChanged) + Q_PROPERTY(QVariant devices READ devices NOTIFY devicesChanged) + +public: + DeviceFinder(DeviceHandler *handler, QObject *parent = nullptr); + ~DeviceFinder(); + + bool scanning() const; + QVariant devices(); + +public slots: + void startSearch(); + void connectToService(const QString &address); + +private slots: + void addDevice(const QBluetoothDeviceInfo&); + void scanError(QBluetoothDeviceDiscoveryAgent::Error error); + void scanFinished(); + +signals: + void scanningChanged(); + void devicesChanged(); + +private: + DeviceHandler *m_deviceHandler; + QBluetoothDeviceDiscoveryAgent *m_deviceDiscoveryAgent; + QList m_devices; +}; + +#endif // DEVICEFINDER_H diff --git a/devicehandler.cpp b/devicehandler.cpp new file mode 100644 index 0000000..a04bbf3 --- /dev/null +++ b/devicehandler.cpp @@ -0,0 +1,248 @@ +#include "devicehandler.h" +#include "deviceinfo.h" +#include +#include + +namespace { +const QBluetoothUuid bobbycarServiceUuid{QUuid::fromString(QStringLiteral("0335e46c-f355-4ce6-8076-017de08cee98"))}; +const QBluetoothUuid frontLeftSpeedCharacUuid{QUuid::fromString(QStringLiteral("81287506-8985-4cea-9a58-92fc5ad2c570"))}; +const QBluetoothUuid frontRightSpeedCharacUuid{QUuid::fromString(QStringLiteral("2f326a23-a676-4f87-b5cb-37a8fd7fe466"))}; +const QBluetoothUuid backLeftSpeedCharacUuid{QUuid::fromString(QStringLiteral("a7f951c0-e984-460d-98ed-0d54c64092d5"))}; +const QBluetoothUuid backRightSpeedCharacUuid{QUuid::fromString(QStringLiteral("14efe73f-6e34-49b3-b2c7-b513f3f5aee2"))}; +} + +DeviceHandler::DeviceHandler(QObject *parent) : + BluetoothBaseClass(parent), + m_foundBobbycarService(false), + m_measuring(false), + m_currentValue(0), + m_min(0), m_max(0), m_sum(0), m_avg(0), m_distance(0) +{ +} + +void DeviceHandler::setAddressType(AddressType type) +{ + switch (type) { + case DeviceHandler::AddressType::PublicAddress: + m_addressType = QLowEnergyController::PublicAddress; + break; + case DeviceHandler::AddressType::RandomAddress: + m_addressType = QLowEnergyController::RandomAddress; + break; + } +} + +DeviceHandler::AddressType DeviceHandler::addressType() const +{ + if (m_addressType == QLowEnergyController::RandomAddress) + return DeviceHandler::AddressType::RandomAddress; + + return DeviceHandler::AddressType::PublicAddress; +} + +void DeviceHandler::setDevice(DeviceInfo *device) +{ + clearMessages(); + m_currentDevice = device; + + // Disconnect and delete old connection + if (m_control) { + m_control->disconnectFromDevice(); + delete m_control; + m_control = nullptr; + } + + // Create new controller and connect it if device available + if (m_currentDevice) { + + // Make connections + //! [Connect-Signals-1] + m_control = QLowEnergyController::createCentral(m_currentDevice->getDevice(), this); + //! [Connect-Signals-1] + m_control->setRemoteAddressType(m_addressType); + //! [Connect-Signals-2] + connect(m_control, &QLowEnergyController::serviceDiscovered, + this, &DeviceHandler::serviceDiscovered); + connect(m_control, &QLowEnergyController::discoveryFinished, + this, &DeviceHandler::serviceScanDone); + + connect(m_control, static_cast(&QLowEnergyController::error), + this, [this](QLowEnergyController::Error error) { + Q_UNUSED(error); + setError("Cannot connect to remote device."); + }); + connect(m_control, &QLowEnergyController::connected, this, [this]() { + setInfo("Controller connected. Search services..."); + m_control->discoverServices(); + }); + connect(m_control, &QLowEnergyController::disconnected, this, [this]() { + setError("LowEnergy controller disconnected"); + }); + + // Connect + m_control->connectToDevice(); + //! [Connect-Signals-2] + } +} + +void DeviceHandler::startMeasurement() +{ + if (alive()) { + m_start = QDateTime::currentDateTime(); + m_min = 0; + m_max = 0; + m_avg = 0; + m_sum = 0; + m_distance = 0; + m_measuring = true; + m_measurements.clear(); + emit measuringChanged(); + } +} + +void DeviceHandler::stopMeasurement() +{ + m_measuring = false; + emit measuringChanged(); +} + +void DeviceHandler::serviceDiscovered(const QBluetoothUuid &gatt) +{ + if (gatt == bobbycarServiceUuid) { + setInfo("Bobbycar service discovered. Waiting for service scan to be done..."); + m_foundBobbycarService = true; + } +} + +void DeviceHandler::serviceScanDone() +{ + setInfo("Service scan done."); + + // Delete old service if available + if (m_service) { + delete m_service; + m_service = nullptr; + } + + // If bobbycarService found, create new service + if (m_foundBobbycarService) + m_service = m_control->createServiceObject(bobbycarServiceUuid, this); + + if (m_service) { + connect(m_service, &QLowEnergyService::stateChanged, this, &DeviceHandler::serviceStateChanged); + connect(m_service, &QLowEnergyService::characteristicChanged, this, &DeviceHandler::updateBobbycarValue); + connect(m_service, &QLowEnergyService::descriptorWritten, this, &DeviceHandler::confirmedDescriptorWrite); + m_service->discoverDetails(); + } else { + setError("Bobbycar Service not found."); + } +} + +void DeviceHandler::serviceStateChanged(QLowEnergyService::ServiceState s) +{ + switch (s) { + case QLowEnergyService::DiscoveringServices: + setInfo(tr("Discovering services...")); + break; + case QLowEnergyService::ServiceDiscovered: + { + setInfo(tr("Service discovered.")); + + const QLowEnergyCharacteristic hrChar = m_service->characteristic(frontLeftSpeedCharacUuid); + if (!hrChar.isValid()) { + setError("Bobbycar Data not found."); + break; + } + + m_notificationDesc = hrChar.descriptor(QBluetoothUuid::ClientCharacteristicConfiguration); + if (m_notificationDesc.isValid()) + m_service->writeDescriptor(m_notificationDesc, QByteArray::fromHex("0100")); + + break; + } + default: + //nothing for now + break; + } + + emit aliveChanged(); +} + +void DeviceHandler::updateBobbycarValue(const QLowEnergyCharacteristic &c, const QByteArray &value) +{ + // ignore any other characteristic change -> shouldn't really happen though + if (c.uuid() != frontLeftSpeedCharacUuid) + return; + + bool ok; + float val = value.toFloat(&ok); + if (ok) + addMeasurement(val); + else + qWarning() << "could not parse float" << value; +} + +void DeviceHandler::confirmedDescriptorWrite(const QLowEnergyDescriptor &d, const QByteArray &value) +{ + if (d.isValid() && d == m_notificationDesc && value == QByteArray::fromHex("0000")) { + //disabled notifications -> assume disconnect intent + m_control->disconnectFromDevice(); + delete m_service; + m_service = nullptr; + } +} + +void DeviceHandler::disconnectService() +{ + m_foundBobbycarService = false; + + //disable notifications + if (m_notificationDesc.isValid() && m_service + && m_notificationDesc.value() == QByteArray::fromHex("0100")) { + m_service->writeDescriptor(m_notificationDesc, QByteArray::fromHex("0000")); + } else { + if (m_control) + m_control->disconnectFromDevice(); + + delete m_service; + m_service = nullptr; + } +} + +bool DeviceHandler::measuring() const +{ + return m_measuring; +} + +bool DeviceHandler::alive() const +{ + if (m_service) + return m_service->state() == QLowEnergyService::ServiceDiscovered; + + return false; +} + +int DeviceHandler::time() const +{ + return m_start.secsTo(m_stop); +} + +void DeviceHandler::addMeasurement(float value) +{ + m_currentValue = value; + + // If measuring and value is appropriate + if (m_measuring) { + + m_stop = QDateTime::currentDateTime(); + m_measurements << value; + + m_min = m_min == 0 ? value : qMin(value, m_min); + m_max = qMax(value, m_max); + m_sum += value; + m_avg = (double)m_sum / m_measurements.size(); + m_distance += value * 1000.f * 3600; + } + + emit statsChanged(); +} diff --git a/devicehandler.h b/devicehandler.h new file mode 100644 index 0000000..48d0266 --- /dev/null +++ b/devicehandler.h @@ -0,0 +1,96 @@ +#ifndef DEVICEHANDLER_H +#define DEVICEHANDLER_H + +#include "bluetoothbaseclass.h" + +#include +#include +#include + +#include +#include + +class DeviceInfo; + +class DeviceHandler : public BluetoothBaseClass +{ + Q_OBJECT + + Q_PROPERTY(bool measuring READ measuring NOTIFY measuringChanged) + Q_PROPERTY(bool alive READ alive NOTIFY aliveChanged) + Q_PROPERTY(float speed READ speed NOTIFY statsChanged) + Q_PROPERTY(float maxSpeed READ maxSpeed NOTIFY statsChanged) + Q_PROPERTY(float minSpeed READ minSpeed NOTIFY statsChanged) + Q_PROPERTY(float avgSpeed READ avgSpeed NOTIFY statsChanged) + Q_PROPERTY(int time READ time NOTIFY statsChanged) + Q_PROPERTY(float distance READ distance NOTIFY statsChanged) + Q_PROPERTY(AddressType addressType READ addressType WRITE setAddressType) + +public: + enum class AddressType { + PublicAddress, + RandomAddress + }; + Q_ENUM(AddressType) + + DeviceHandler(QObject *parent = nullptr); + + void setDevice(DeviceInfo *device); + void setAddressType(AddressType type); + AddressType addressType() const; + + bool measuring() const; + bool alive() const; + + // Statistics + float speed() const { return m_currentValue; } + int time() const; + float avgSpeed() const { return m_avg; } + float maxSpeed() const { return m_max; } + float minSpeed() const { return m_min; } + float distance() const { return m_distance; } + +signals: + void measuringChanged(); + void aliveChanged(); + void statsChanged(); + +public slots: + void startMeasurement(); + void stopMeasurement(); + void disconnectService(); + +private: + //QLowEnergyController + void serviceDiscovered(const QBluetoothUuid &); + void serviceScanDone(); + + //QLowEnergyService + void serviceStateChanged(QLowEnergyService::ServiceState s); + void updateBobbycarValue(const QLowEnergyCharacteristic &c, + const QByteArray &value); + void confirmedDescriptorWrite(const QLowEnergyDescriptor &d, + const QByteArray &value); + +private: + void addMeasurement(float value); + + QLowEnergyController *m_control = nullptr; + QLowEnergyService *m_service = nullptr; + QLowEnergyDescriptor m_notificationDesc; + DeviceInfo *m_currentDevice = nullptr; + + bool m_foundBobbycarService; + bool m_measuring; + float m_currentValue, m_min, m_max, m_sum; + float m_avg, m_distance; + + // Statistics + QDateTime m_start; + QDateTime m_stop; + + QVector m_measurements; + QLowEnergyController::RemoteAddressType m_addressType = QLowEnergyController::PublicAddress; +}; + +#endif // DEVICEHANDLER_H diff --git a/deviceinfo.cpp b/deviceinfo.cpp new file mode 100644 index 0000000..1114711 --- /dev/null +++ b/deviceinfo.cpp @@ -0,0 +1,29 @@ +#include "deviceinfo.h" +#include +#include + +DeviceInfo::DeviceInfo(const QBluetoothDeviceInfo &info): + m_device(info) +{ +} + +QBluetoothDeviceInfo DeviceInfo::getDevice() const +{ + return m_device; +} + +QString DeviceInfo::getName() const +{ + return m_device.name(); +} + +QString DeviceInfo::getAddress() const +{ + return m_device.address().toString(); +} + +void DeviceInfo::setDevice(const QBluetoothDeviceInfo &device) +{ + m_device = device; + emit deviceChanged(); +} diff --git a/deviceinfo.h b/deviceinfo.h new file mode 100644 index 0000000..70ce103 --- /dev/null +++ b/deviceinfo.h @@ -0,0 +1,29 @@ +#ifndef DEVICEINFO_H +#define DEVICEINFO_H + +#include +#include +#include + +class DeviceInfo: public QObject +{ + Q_OBJECT + Q_PROPERTY(QString deviceName READ getName NOTIFY deviceChanged) + Q_PROPERTY(QString deviceAddress READ getAddress NOTIFY deviceChanged) + +public: + DeviceInfo(const QBluetoothDeviceInfo &device); + + void setDevice(const QBluetoothDeviceInfo &device); + QString getName() const; + QString getAddress() const; + QBluetoothDeviceInfo getDevice() const; + +signals: + void deviceChanged(); + +private: + QBluetoothDeviceInfo m_device; +}; + +#endif // DEVICEINFO_H diff --git a/images.qrc b/images.qrc new file mode 100644 index 0000000..f33d832 --- /dev/null +++ b/images.qrc @@ -0,0 +1,6 @@ + + + qml/images/logo.png + qml/images/bt_off_to_on.png + + diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000..4112fe5 --- /dev/null +++ b/main.cpp @@ -0,0 +1,30 @@ +#include +#include +#include +#include + +#include "connectionhandler.h" +#include "devicefinder.h" +#include "devicehandler.h" + +int main(int argc, char *argv[]) +{ + QLoggingCategory::setFilterRules(QStringLiteral("qt.bluetooth* = true")); + QGuiApplication::setAttribute(Qt::AA_EnableHighDpiScaling); + QGuiApplication app(argc, argv); + + ConnectionHandler connectionHandler; + DeviceHandler deviceHandler; + DeviceFinder deviceFinder(&deviceHandler); + + qmlRegisterUncreatableType("Shared", 1, 0, "AddressType", "Enum is not a type"); + + QQmlApplicationEngine engine; + engine.rootContext()->setContextProperty("connectionHandler", &connectionHandler); + engine.rootContext()->setContextProperty("deviceFinder", &deviceFinder); + engine.rootContext()->setContextProperty("deviceHandler", &deviceHandler); + + engine.load(QUrl(QStringLiteral("qrc:/qml/main.qml"))); + + return app.exec(); +} diff --git a/qml.qrc b/qml.qrc new file mode 100644 index 0000000..bab9635 --- /dev/null +++ b/qml.qrc @@ -0,0 +1,18 @@ + + + qml/BluetoothAlarmDialog.qml + qml/main.qml + qml/SplashScreen.qml + qml/GameSettings.qml + qml/App.qml + qml/TitleBar.qml + qml/Connect.qml + qml/Measure.qml + qml/Stats.qml + qml/GameButton.qml + qml/GamePage.qml + qml/BottomLine.qml + qml/StatsLabel.qml + qml/qmldir + + diff --git a/qml/App.qml b/qml/App.qml new file mode 100644 index 0000000..e4da72c --- /dev/null +++ b/qml/App.qml @@ -0,0 +1,80 @@ +import QtQuick 2.5 + +Item { + id: app + anchors.fill: parent + opacity: 0.0 + + Behavior on opacity { NumberAnimation { duration: 500 } } + + property var lastPages: [] + property int __currentIndex: 0 + + function init() + { + opacity = 1.0 + showPage("Connect.qml") + } + + function prevPage() + { + lastPages.pop() + pageLoader.setSource(lastPages[lastPages.length-1]) + __currentIndex = lastPages.length-1; + } + + function showPage(name) + { + lastPages.push(name) + pageLoader.setSource(name) + __currentIndex = lastPages.length-1; + } + + TitleBar { + id: titleBar + currentIndex: __currentIndex + + onTitleClicked: { + if (index < __currentIndex) + pageLoader.item.close() + } + } + + Loader { + id: pageLoader + anchors.left: parent.left + anchors.right: parent.right + anchors.top: titleBar.bottom + anchors.bottom: parent.bottom + + onStatusChanged: { + if (status === Loader.Ready) + { + pageLoader.item.init(); + pageLoader.item.forceActiveFocus() + } + } + } + + Keys.onReleased: { + switch (event.key) { + case Qt.Key_Escape: + case Qt.Key_Back: { + if (__currentIndex > 0) { + pageLoader.item.close() + event.accepted = true + } else { + Qt.quit() + } + break; + } + default: break; + } + } + + BluetoothAlarmDialog { + id: btAlarmDialog + anchors.fill: parent + visible: !connectionHandler.alive + } +} diff --git a/qml/BluetoothAlarmDialog.qml b/qml/BluetoothAlarmDialog.qml new file mode 100644 index 0000000..619739c --- /dev/null +++ b/qml/BluetoothAlarmDialog.qml @@ -0,0 +1,72 @@ +import QtQuick 2.5 + +Item { + id: root + 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.mediumFontSize + color: GameSettings.textColor + text: 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.bigFontSize + text: qsTr("Quit") + } + } + } + } +} + diff --git a/qml/BottomLine.qml b/qml/BottomLine.qml new file mode 100644 index 0000000..20f338d --- /dev/null +++ b/qml/BottomLine.qml @@ -0,0 +1,9 @@ +import QtQuick 2.5 + +Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + width: parent.width * 0.85 + height: parent.height * 0.05 + radius: height*0.5 +} diff --git a/qml/Connect.qml b/qml/Connect.qml new file mode 100644 index 0000000..ff13f8d --- /dev/null +++ b/qml/Connect.qml @@ -0,0 +1,138 @@ +import QtQuick 2.5 +import Shared 1.0 + +GamePage { + + errorMessage: deviceFinder.error + infoMessage: deviceFinder.info + + Rectangle { + id: viewContainer + anchors.top: parent.top + anchors.bottom: + // only BlueZ platform has address type selection + connectionHandler.requiresAddressType ? addressTypeButton.top : searchButton.top + anchors.topMargin: GameSettings.fieldMargin + messageHeight + anchors.bottomMargin: GameSettings.fieldMargin + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width - GameSettings.fieldMargin*2 + color: GameSettings.viewColor + radius: GameSettings.buttonRadius + + + Text { + id: title + width: parent.width + height: GameSettings.fieldHeight + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + color: GameSettings.textColor + font.pixelSize: GameSettings.mediumFontSize + text: qsTr("FOUND DEVICES") + + BottomLine { + height: 1; + width: parent.width + color: "#898989" + } + } + + + ListView { + id: devices + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.top: title.bottom + model: deviceFinder.devices + clip: true + + delegate: Rectangle { + id: box + height:GameSettings.fieldHeight * 1.2 + width: parent.width + color: index % 2 === 0 ? GameSettings.delegate1Color : GameSettings.delegate2Color + + MouseArea { + anchors.fill: parent + onClicked: { + deviceFinder.connectToService(modelData.deviceAddress); + app.showPage("Measure.qml") + } + } + + Text { + id: device + font.pixelSize: GameSettings.smallFontSize + text: modelData.deviceName + anchors.top: parent.top + anchors.topMargin: parent.height * 0.1 + anchors.leftMargin: parent.height * 0.1 + anchors.left: parent.left + color: GameSettings.textColor + } + + Text { + id: deviceAddress + font.pixelSize: GameSettings.smallFontSize + text: modelData.deviceAddress + anchors.bottom: parent.bottom + anchors.bottomMargin: parent.height * 0.1 + anchors.rightMargin: parent.height * 0.1 + 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: connectionHandler.requiresAddressType // only required on BlueZ + state: "public" + onClicked: state == "public" ? state = "random" : state = "public" + + states: [ + State { + name: "public" + PropertyChanges { target: addressTypeText; text: qsTr("Public Address") } + PropertyChanges { target: deviceHandler; addressType: AddressType.PublicAddress } + }, + State { + name: "random" + PropertyChanges { target: addressTypeText; text: qsTr("Random Address") } + PropertyChanges { target: deviceHandler; addressType: AddressType.RandomAddress } + } + ] + + Text { + id: addressTypeText + anchors.centerIn: parent + font.pixelSize: GameSettings.tinyFontSize + color: GameSettings.textColor + } + } + + GameButton { + id: searchButton + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: GameSettings.fieldMargin + width: viewContainer.width + height: GameSettings.fieldHeight + enabled: !deviceFinder.scanning + onClicked: deviceFinder.startSearch() + + Text { + anchors.centerIn: parent + font.pixelSize: GameSettings.tinyFontSize + text: qsTr("START SEARCH") + color: searchButton.enabled ? GameSettings.textColor : GameSettings.disabledTextColor + } + } +} diff --git a/qml/GameButton.qml b/qml/GameButton.qml new file mode 100644 index 0000000..4a5b618 --- /dev/null +++ b/qml/GameButton.qml @@ -0,0 +1,38 @@ +import QtQuick 2.5 +import "." + +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: checkColor() + onReleased: checkColor() + onClicked: { + checkColor() + button.clicked() + } + } +} diff --git a/qml/GamePage.qml b/qml/GamePage.qml new file mode 100644 index 0000000..e0afe4e --- /dev/null +++ b/qml/GamePage.qml @@ -0,0 +1,43 @@ +import QtQuick 2.5 +import "." + +Item { + anchors.fill: parent + + property string errorMessage: "" + property string infoMessage: "" + property real messageHeight: msg.height + property bool hasError: errorMessage != "" + property bool hasInfo: infoMessage != "" + + function init() + { + } + + function close() + { + app.prevPage() + } + + Rectangle { + id: msg + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: GameSettings.fieldHeight + color: hasError ? GameSettings.errorColor : GameSettings.infoColor + visible: hasError || hasInfo + + Text { + id: error + anchors.fill: parent + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + minimumPixelSize: 5 + font.pixelSize: GameSettings.smallFontSize + fontSizeMode: Text.Fit + color: GameSettings.textColor + text: hasError ? errorMessage : infoMessage + } + } +} diff --git a/qml/GameSettings.qml b/qml/GameSettings.qml new file mode 100644 index 0000000..09e20fe --- /dev/null +++ b/qml/GameSettings.qml @@ -0,0 +1,51 @@ +pragma Singleton +import QtQuick 2.5 + +Item { + property int wHeight + property int wWidth + + // Colors + readonly property color backgroundColor: "#2d3037" + readonly property color buttonColor: "#202227" + readonly property color buttonPressedColor: "#6ccaf2" + readonly property color disabledButtonColor: "#555555" + readonly property color viewColor: "#202227" + readonly property color delegate1Color: Qt.darker(viewColor, 1.2) + readonly property color delegate2Color: Qt.lighter(viewColor, 1.2) + readonly property color textColor: "#ffffff" + readonly property color textDarkColor: "#232323" + readonly property color disabledTextColor: "#777777" + readonly property color sliderColor: "#6ccaf2" + readonly property color errorColor: "#ba3f62" + readonly property color infoColor: "#3fba62" + + // Font sizes + property real microFontSize: hugeFontSize * 0.2 + property real tinyFontSize: hugeFontSize * 0.4 + property real smallTinyFontSize: hugeFontSize * 0.5 + property real smallFontSize: hugeFontSize * 0.6 + property real mediumFontSize: hugeFontSize * 0.7 + property real bigFontSize: hugeFontSize * 0.8 + property real largeFontSize: hugeFontSize * 0.9 + property real hugeFontSize: (wWidth + wHeight) * 0.03 + property real giganticFontSize: (wWidth + wHeight) * 0.04 + + // 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 widthForHeight(h, ss) + { + return h/ss.height * ss.width; + } + + function heightForWidth(w, ss) + { + return w/ss.width * ss.height; + } + +} diff --git a/qml/Measure.qml b/qml/Measure.qml new file mode 100644 index 0000000..f026510 --- /dev/null +++ b/qml/Measure.qml @@ -0,0 +1,194 @@ +import QtQuick 2.5 + +GamePage { + id: measurePage + + errorMessage: deviceHandler.error + infoMessage: deviceHandler.info + + property real __timeCounter: 0; + property real __maxTimeCount: 60 + property string relaxText: qsTr("FAST!\nWhen you are ready, press Start. You have %1s time to increase speed so much as possible.\nGood luck!").arg(__maxTimeCount) + + function close() + { + deviceHandler.stopMeasurement(); + deviceHandler.disconnectService(); + app.prevPage(); + } + + function start() + { + if (!deviceHandler.measuring) { + __timeCounter = 0; + deviceHandler.startMeasurement() + } + } + + function stop() + { + if (deviceHandler.measuring) { + deviceHandler.stopMeasurement() + } + + app.showPage("Stats.qml") + } + + Timer { + id: measureTimer + interval: 1000 + running: deviceHandler.measuring + repeat: true + onTriggered: { + __timeCounter++; + if (__timeCounter >= __maxTimeCount) + measurePage.stop() + } + } + + Column { + anchors.centerIn: parent + spacing: GameSettings.fieldHeight * 0.5 + + Rectangle { + id: circle + 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: hintText + anchors.centerIn: parent + anchors.verticalCenterOffset: -parent.height*0.1 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + width: parent.width * 0.8 + height: parent.height * 0.6 + wrapMode: Text.WordWrap + text: measurePage.relaxText + visible: !deviceHandler.measuring + color: GameSettings.textColor + fontSizeMode: Text.Fit + minimumPixelSize: 10 + font.pixelSize: GameSettings.mediumFontSize + } + + Text { + id: text + anchors.centerIn: parent + anchors.verticalCenterOffset: -parent.height*0.15 + font.pixelSize: parent.width * 0.45 + text: deviceHandler.speed.toFixed(0) + visible: deviceHandler.measuring + color: GameSettings.textColor + } + + 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: deviceHandler.measuring + + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: deviceHandler.minSpeed.toFixed(0) + color: GameSettings.textColor + font.pixelSize: GameSettings.hugeFontSize + + Text { + anchors.left: parent.left + anchors.bottom: parent.top + font.pixelSize: parent.font.pixelSize*0.8 + color: parent.color + text: "MIN" + } + } + + Text { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: deviceHandler.maxSpeed.toFixed(0) + color: GameSettings.textColor + font.pixelSize: GameSettings.hugeFontSize + + Text { + anchors.right: parent.right + anchors.bottom: parent.top + font.pixelSize: parent.font.pixelSize*0.8 + color: parent.color + text: "MAX" + } + } + } + + Image { + id: bobbycar + anchors.horizontalCenter: minMaxContainer.horizontalCenter + anchors.verticalCenter: minMaxContainer.bottom + width: parent.width * 0.2 + height: width + source: "images/logo.png" + smooth: true + antialiasing: true + + SequentialAnimation{ + id: bobbycarAnim + running: deviceHandler.alive + loops: Animation.Infinite + alwaysRunToEnd: true + PropertyAnimation { target: bobbycar; property: "scale"; to: 1.2; duration: 500; easing.type: Easing.InQuad } + PropertyAnimation { target: bobbycar; property: "scale"; to: 1.0; duration: 500; easing.type: Easing.OutQuad } + } + } + } + + Rectangle { + id: timeSlider + color: GameSettings.viewColor + anchors.horizontalCenter: parent.horizontalCenter + width: circle.width + height: GameSettings.fieldHeight + radius: GameSettings.buttonRadius + + Rectangle { + height: parent.height + radius: parent.radius + color: GameSettings.sliderColor + width: Math.min(1.0,__timeCounter / __maxTimeCount) * parent.width + } + + Text { + anchors.centerIn: parent + color: "gray" + text: (__maxTimeCount - __timeCounter).toFixed(0) + " s" + font.pixelSize: GameSettings.bigFontSize + } + } + } + + GameButton { + id: startButton + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: GameSettings.fieldMargin + width: circle.width + height: GameSettings.fieldHeight + enabled: !deviceHandler.measuring + radius: GameSettings.buttonRadius + + onClicked: start() + + Text { + anchors.centerIn: parent + font.pixelSize: GameSettings.tinyFontSize + text: qsTr("START") + color: startButton.enabled ? GameSettings.textColor : GameSettings.disabledTextColor + } + } +} diff --git a/qml/SplashScreen.qml b/qml/SplashScreen.qml new file mode 100644 index 0000000..ebcb663 --- /dev/null +++ b/qml/SplashScreen.qml @@ -0,0 +1,40 @@ +import QtQuick 2.5 +import "." + +Item { + id: root + anchors.fill: parent + + property bool appIsReady: false + property bool splashIsReady: false + + property bool ready: appIsReady && splashIsReady + onReadyChanged: if (ready) readyToGo(); + + signal readyToGo() + + function appReady() + { + appIsReady = true + } + + function errorInLoadingApp() + { + Qt.quit() + } + + 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: splashIsReady = true + } + + Component.onCompleted: splashTimer.start() +} diff --git a/qml/Stats.qml b/qml/Stats.qml new file mode 100644 index 0000000..7b6827d --- /dev/null +++ b/qml/Stats.qml @@ -0,0 +1,49 @@ +import QtQuick 2.5 + +GamePage { + + Column { + anchors.centerIn: parent + width: parent.width + + Text { + anchors.horizontalCenter: parent.horizontalCenter + font.pixelSize: GameSettings.hugeFontSize + color: GameSettings.textColor + text: qsTr("RESULT") + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + font.pixelSize: GameSettings.giganticFontSize*3 + color: GameSettings.textColor + text: (deviceHandler.maxSpeed - deviceHandler.minSpeed).toFixed(0) + } + + Item { + height: GameSettings.fieldHeight + width: 1 + } + + StatsLabel { + title: qsTr("MIN") + value: deviceHandler.minSpeed.toFixed(0) + } + + StatsLabel { + title: qsTr("MAX") + value: deviceHandler.maxSpeed.toFixed(0) + } + + StatsLabel { + title: qsTr("AVG") + value: deviceHandler.avgSpeed.toFixed(1) + } + + + StatsLabel { + title: qsTr("DISTANCE") + value: deviceHandler.distance.toFixed(3) + } + } +} diff --git a/qml/StatsLabel.qml b/qml/StatsLabel.qml new file mode 100644 index 0000000..966b791 --- /dev/null +++ b/qml/StatsLabel.qml @@ -0,0 +1,32 @@ +import QtQuick 2.5 +import "." + +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 + } +} diff --git a/qml/TitleBar.qml b/qml/TitleBar.qml new file mode 100644 index 0000000..1e980a3 --- /dev/null +++ b/qml/TitleBar.qml @@ -0,0 +1,47 @@ +import QtQuick 2.5 + +Rectangle { + id: titleBar + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: GameSettings.fieldHeight + color: GameSettings.viewColor + + property var __titles: ["CONNECT", "MEASURE", "STATS"] + property int currentIndex: 0 + + signal titleClicked(int index) + + Repeater { + model: 3 + Text { + width: titleBar.width / 3 + height: titleBar.height + x: index * width + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + text: __titles[index] + font.pixelSize: GameSettings.tinyFontSize + color: titleBar.currentIndex === index ? GameSettings.textColor : GameSettings.disabledTextColor + + MouseArea { + anchors.fill: parent + onClicked: titleClicked(index) + } + } + } + + + Item { + anchors.bottom: parent.bottom + width: parent.width / 3 + height: parent.height + x: currentIndex * width + + BottomLine{} + + Behavior on x { NumberAnimation { duration: 200 } } + } + +} diff --git a/qml/images/bt_off_to_on.png b/qml/images/bt_off_to_on.png new file mode 100644 index 0000000..5ea1f3f Binary files /dev/null and b/qml/images/bt_off_to_on.png differ diff --git a/qml/images/logo.png b/qml/images/logo.png new file mode 100644 index 0000000..19aa134 Binary files /dev/null and b/qml/images/logo.png differ diff --git a/qml/main.qml b/qml/main.qml new file mode 100644 index 0000000..036c96d --- /dev/null +++ b/qml/main.qml @@ -0,0 +1,55 @@ +import QtQuick 2.7 +import QtQuick.Window 2.2 +import "." + +Window { + id: wroot + visible: true + width: 720 * .7 + height: 1240 * .7 + title: qsTr("Bobbycar") + color: GameSettings.backgroundColor + + Component.onCompleted: { + GameSettings.wWidth = Qt.binding(function() {return width}) + GameSettings.wHeight = Qt.binding(function() {return height}) + } + + Loader { + id: splashLoader + anchors.fill: parent + source: "SplashScreen.qml" + asynchronous: false + visible: true + + onStatusChanged: { + if (status === Loader.Ready) { + appLoader.setSource("App.qml"); + } + } + } + + Connections { + target: splashLoader.item + onReadyToGo: { + appLoader.visible = true + appLoader.item.init() + splashLoader.visible = false + splashLoader.setSource("") + appLoader.item.forceActiveFocus(); + } + } + + Loader { + id: appLoader + anchors.fill: parent + visible: false + asynchronous: true + onStatusChanged: { + if (status === Loader.Ready) + splashLoader.item.appReady() + if (status === Loader.Error) + splashLoader.item.errorInLoadingApp(); + } + } +} diff --git a/qml/qmldir b/qml/qmldir new file mode 100644 index 0000000..5e0d2b5 --- /dev/null +++ b/qml/qmldir @@ -0,0 +1 @@ +singleton GameSettings 1.0 GameSettings.qml