Revision control

++ // Copyright (C) 2021 The Qt Company Ltd.
++ // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
++
++ #include "appmodel.h"
++ #include "openmeteobackend.h"
++ #include "openweathermapbackend.h"
++ #include "weatherapibackend.h"
++
++ #include <QtPositioning/qgeocircle.h>
++ #include <QtPositioning/qgeocoordinate.h>
++ #if QT_CONFIG(permissions)
++ #include <QtCore/qpermissions.h>
++ #endif
++ #include <QtCore/qcoreapplication.h>
++ #include <QtCore/qloggingcategory.h>
++ #include <QtCore/qtimer.h>
++
++ Q_LOGGING_CATEGORY(requestsLog, "wapp.requests")
++
++ WeatherData::WeatherData(QObject *parent) :
++ QObject(parent)
++ {
++ }
++
++ WeatherData::WeatherData(const WeatherData &other) :
++ QObject(nullptr),
++ m_dayOfWeek(other.m_dayOfWeek),
++ m_weather(other.m_weather),
++ m_weatherDescription(other.m_weatherDescription),
++ m_temperature(other.m_temperature)
++ {
++ }
++
++ WeatherData::WeatherData(const WeatherInfo &other)
++ : QObject(nullptr),
++ m_dayOfWeek(other.m_dayOfWeek),
++ m_weather(other.m_weatherIconId),
++ m_weatherDescription(other.m_weatherDescription),
++ m_temperature(other.m_temperature)
++ {
++ }
++
++ QString WeatherData::dayOfWeek() const
++ {
++ return m_dayOfWeek;
++ }
++
++ QString WeatherData::weatherIcon() const
++ {
++ return m_weather;
++ }
++
++ QString WeatherData::weatherDescription() const
++ {
++ return m_weatherDescription;
++ }
++
++ QString WeatherData::temperature() const
++ {
++ return m_temperature;
++ }
++
++ void WeatherData::setDayOfWeek(const QString &value)
++ {
++ m_dayOfWeek = value;
++ emit dataChanged();
++ }
++
++ void WeatherData::setWeatherIcon(const QString &value)
++ {
++ m_weather = value;
++ emit dataChanged();
++ }
++
++ void WeatherData::setWeatherDescription(const QString &value)
++ {
++ m_weatherDescription = value;
++ emit dataChanged();
++ }
++
++ void WeatherData::setTemperature(const QString &value)
++ {
++ m_temperature = value;
++ emit dataChanged();
++ }
++
++ /*
++ The class is used as a cache for the weather information.
++ It contains a map to cache weather for cities.
++ The gps location is cached separately.
++
++ For the coordiante search we do not compare the coordinate directly, but
++ check if it's within a circle of 3 km radius (we assume that the weather
++ does not really change within that radius).
++
++ The cache returns a pair with empty location and weather data if no data
++ is found, or if the data is outdated.
++ */
++ class WeatherDataCache
++ {
++ public:
++ WeatherDataCache() = default;
++
++ using WeatherDataPair = QPair<QString, QList<WeatherInfo>>;
++
++ WeatherDataPair getWeatherData(const QString &name) const;
++ WeatherDataPair getWeatherData(const QGeoCoordinate &coordinate) const;
++
++ void addCacheElement(const LocationInfo &location, const QList<WeatherInfo> &info);
++
++ static bool isCacheResultValid(const WeatherDataPair &result);
++
++ private:
++ struct CacheItem
++ {
++ qint64 m_cacheTime;
++ QList<WeatherInfo> m_weatherData;
++ };
++
++ QMap<QString, CacheItem> m_cityCache;
++
++ QGeoCoordinate m_gpsLocation;
++ QString m_gpsName;
++ CacheItem m_gpsData;
++
++ static const qint64 kCacheTimeoutInterval = 3600; // 1 hour
++ static const int kCircleRadius = 3000; // 3 km
++ };
++
++ WeatherDataCache::WeatherDataPair WeatherDataCache::getWeatherData(const QString &name) const
++ {
++ if (m_cityCache.contains(name)) {
++ const qint64 currentTime = QDateTime::currentSecsSinceEpoch();
++ const auto &item = m_cityCache.value(name);
++ if (currentTime - item.m_cacheTime < kCacheTimeoutInterval)
++ return qMakePair(name, item.m_weatherData);
++ }
++ return qMakePair(QString(), QList<WeatherInfo>());
++ }
++
++ WeatherDataCache::WeatherDataPair
++ WeatherDataCache::getWeatherData(const QGeoCoordinate &coordinate) const
++ {
++ if (m_gpsLocation.isValid() && !m_gpsName.isEmpty()) {
++ const QGeoCircle area(m_gpsLocation, kCircleRadius);
++ if (area.contains(coordinate)) {
++ const qint64 currentTime = QDateTime::currentSecsSinceEpoch();
++ if (currentTime - m_gpsData.m_cacheTime < kCacheTimeoutInterval)
++ return qMakePair(m_gpsName, m_gpsData.m_weatherData);
++ }
++ }
++ return qMakePair(QString(), QList<WeatherInfo>());
++ }
++
++ void WeatherDataCache::addCacheElement(const LocationInfo &location,
++ const QList<WeatherInfo> &info)
++ {
++ // It it expected that we have valid QGeoCoordinate only when the weather
++ // is received based on coordinates.
++ const qint64 currentTime = QDateTime::currentSecsSinceEpoch();
++ if (location.m_coordinate.isValid()) {
++ m_gpsLocation = location.m_coordinate;
++ m_gpsName = location.m_name;
++ m_gpsData = { currentTime, info };
++ } else {
++ m_cityCache[location.m_name] = { currentTime, info };
++ }
++ }
++
++ bool WeatherDataCache::isCacheResultValid(const WeatherDataCache::WeatherDataPair &result)
++ {
++ return !result.first.isEmpty() && !result.second.isEmpty();
++ }
++
++ class AppModelPrivate
++ {
++ public:
++ QGeoPositionInfoSource *src = nullptr;
++ QGeoCoordinate coord;
++ QString city;
++ WeatherData now;
++ QList<WeatherData*> forecast;
++ QQmlListProperty<WeatherData> *fcProp = nullptr;
++ bool ready = false;
++ bool useGps = true;
++ WeatherDataCache m_dataCache;
++ ProviderBackend *m_currentBackend = nullptr;
++ QList<ProviderBackend*> m_supportedBackends;
++ qsizetype m_currentBackendIndex = 0;
++ };
++
++ static void forecastAppend(QQmlListProperty<WeatherData> *prop, WeatherData *val)
++ {
++ Q_UNUSED(val);
++ Q_UNUSED(prop);
++ }
++
++ static WeatherData *forecastAt(QQmlListProperty<WeatherData> *prop, qsizetype index)
++ {
++ AppModelPrivate *d = static_cast<AppModelPrivate*>(prop->data);
++ return d->forecast.at(index);
++ }
++
++ static qsizetype forecastCount(QQmlListProperty<WeatherData> *prop)
++ {
++ AppModelPrivate *d = static_cast<AppModelPrivate*>(prop->data);
++ return d->forecast.size();
++ }
++
++ static void forecastClear(QQmlListProperty<WeatherData> *prop)
++ {
++ static_cast<AppModelPrivate*>(prop->data)->forecast.clear();
++ }
++
++ //! [0]
++ AppModel::AppModel(QObject *parent) :
++ QObject(parent),
++ d(new AppModelPrivate)
++ {
++ //! [0]
++ d->fcProp = new QQmlListProperty<WeatherData>(this, d, forecastAppend,
++ forecastCount,
++ forecastAt,
++ forecastClear);
++
++ d->m_supportedBackends.push_back(new OpenWeatherMapBackend(this));
++ d->m_supportedBackends.push_back(new WeatherApiBackend(this));
++ d->m_supportedBackends.push_back(new OpenMeteoBackend(this));
++ registerBackend(0);
++
++ //! [1]
++ d->src = QGeoPositionInfoSource::createDefaultSource(this);
++
++ if (d->src) {
++ d->useGps = true;
++ connect(d->src, &QGeoPositionInfoSource::positionUpdated,
++ this, &AppModel::positionUpdated);
++ connect(d->src, &QGeoPositionInfoSource::errorOccurred,
++ this, &AppModel::positionError);
++ #if QT_CONFIG(permissions)
++ QLocationPermission permission;
++ permission.setAccuracy(QLocationPermission::Precise);
++ permission.setAvailability(QLocationPermission::WhenInUse);
++
++ switch (qApp->checkPermission(permission)) {
++ case Qt::PermissionStatus::Undetermined:
++ qApp->requestPermission(permission, [this] (const QPermission& permission) {
++ if (permission.status() == Qt::PermissionStatus::Granted)
++ d->src->startUpdates();
++ else
++ positionError(QGeoPositionInfoSource::AccessError);
++ });
++ break;
++ case Qt::PermissionStatus::Denied:
++ qWarning("Location permission is denied");
++ positionError(QGeoPositionInfoSource::AccessError);
++ break;
++ case Qt::PermissionStatus::Granted:
++ d->src->startUpdates();
++ break;
++ }
++ #else
++ d->src->startUpdates();
++ #endif
++ } else {
++ d->useGps = false;
++ d->city = "Brisbane";
++ emit cityChanged();
++ requestWeatherByCity();
++ }
++
++ QTimer *refreshTimer = new QTimer(this);
++ connect(refreshTimer, &QTimer::timeout, this, &AppModel::refreshWeather);
++ using namespace std::chrono;
++ refreshTimer->start(60s);
++ }
++ //! [1]
++
++ AppModel::~AppModel()
++ {
++ if (d->src)
++ d->src->stopUpdates();
++ if (d->fcProp)
++ delete d->fcProp;
++ qDeleteAll(d->forecast);
++ delete d;
++ }
++
++ //! [2]
++ void AppModel::positionUpdated(QGeoPositionInfo gpsPos)
++ {
++ d->coord = gpsPos.coordinate();
++
++ if (!d->useGps)
++ return;
++
++ requestWeatherByCoordinates();
++ }
++ //! [2]
++
++ void AppModel::positionError(QGeoPositionInfoSource::Error e)
++ {
++ Q_UNUSED(e);
++ qWarning() << "Position source error. Falling back to simulation mode.";
++
++ // activate simulation mode
++ if (d->useGps) {
++ d->useGps = false;
++ d->city = "Brisbane";
++ emit cityChanged();
++ requestWeatherByCity();
++ }
++ }
++
++ void AppModel::refreshWeather()
++ {
++ if (d->city.isEmpty()) {
++ qCDebug(requestsLog) << "refreshing weather skipped (no city)";
++ return;
++ }
++ qCDebug(requestsLog) << "refreshing weather";
++ requestWeatherByCity();
++ }
++
++ void AppModel::handleWeatherData(const LocationInfo &location,
++ const QList<WeatherInfo> &weatherDetails)
++ {
++ if (applyWeatherData(location.m_name, weatherDetails))
++ d->m_dataCache.addCacheElement(location, weatherDetails);
++ }
++
++ void AppModel::switchToNextBackend()
++ {
++ deregisterCurrentBackend();
++ registerBackend(d->m_currentBackendIndex + 1);
++ if (d->m_currentBackend) {
++ // repeat the query
++ if (d->useGps)
++ requestWeatherByCoordinates();
++ else
++ requestWeatherByCity();
++ } else {
++ qWarning("The application has iterated through all of the weather backends, "
++ "and none of them seems to respond now. Please wait until any of the "
++ "backends becomes available again.");
++ }
++ }
++
++ bool AppModel::applyWeatherData(const QString &city, const QList<WeatherInfo> &weatherDetails)
++ {
++ // Check that we didn't get outdated weather data. The city should match,
++ // if only we do not use GPS.
++ if (city != d->city && !d->useGps)
++ return false;
++
++ if (city != d->city && d->useGps) {
++ d->city = city;
++ emit cityChanged();
++ }
++
++ // delete previous forecast
++ qDeleteAll(d->forecast);
++ d->forecast.clear();
++
++ // The first item in the list represents current weather.
++ if (!weatherDetails.isEmpty()) {
++ d->now.setTemperature(weatherDetails.first().m_temperature);
++ d->now.setWeatherIcon(weatherDetails.first().m_weatherIconId);
++ d->now.setWeatherDescription(weatherDetails.first().m_weatherDescription);
++ }
++
++ // The other items represent weather forecast. The amount of items depends
++ // on the provider backend.
++ for (qsizetype i = 1; i < weatherDetails.size(); ++i) {
++ WeatherData *forecastEntry = new WeatherData(weatherDetails.at(i));
++ d->forecast.append(forecastEntry);
++ }
++
++ if (!d->ready) {
++ d->ready = true;
++ emit readyChanged();
++ }
++
++ emit weatherChanged();
++
++ return true;
++ }
++
++ void AppModel::requestWeatherByCoordinates()
++ {
++ const auto cacheResult = d->m_dataCache.getWeatherData(d->coord);
++ if (WeatherDataCache::isCacheResultValid(cacheResult))
++ applyWeatherData(cacheResult.first, cacheResult.second);
++ else if (d->m_currentBackend)
++ d->m_currentBackend->requestWeatherInfo(d->coord);
++ }
++
++ void AppModel::requestWeatherByCity()
++ {
++ const auto cacheResult = d->m_dataCache.getWeatherData(d->city);
++ if (WeatherDataCache::isCacheResultValid(cacheResult))
++ applyWeatherData(cacheResult.first, cacheResult.second);
++ else if (d->m_currentBackend)
++ d->m_currentBackend->requestWeatherInfo(d->city);
++ }
++
++ void AppModel::registerBackend(qsizetype index)
++ {
++ if (index >= 0 && index < d->m_supportedBackends.size()) {
++ d->m_currentBackend = d->m_supportedBackends.at(index);
++ d->m_currentBackendIndex = index;
++ connect(d->m_currentBackend, &ProviderBackend::weatherInformation,
++ this, &AppModel::handleWeatherData);
++ connect(d->m_currentBackend, &ProviderBackend::errorOccurred,
++ this, &AppModel::switchToNextBackend);
++ }
++ }
++
++ void AppModel::deregisterCurrentBackend()
++ {
++ if (d->m_currentBackend) {
++ disconnect(d->m_currentBackend, &ProviderBackend::weatherInformation,
++ this, &AppModel::handleWeatherData);
++ disconnect(d->m_currentBackend, &ProviderBackend::errorOccurred,
++ this, &AppModel::switchToNextBackend);
++ d->m_currentBackend = nullptr;
++ }
++ }
++
++ bool AppModel::hasValidCity() const
++ {
++ return (!(d->city.isEmpty()) && d->city.size() > 1 && d->city != "");
++ }
++
++ bool AppModel::hasValidWeather() const
++ {
++ return hasValidCity() && (!(d->now.weatherIcon().isEmpty()) &&
++ (d->now.weatherIcon().size() > 1) &&
++ d->now.weatherIcon() != "");
++ }
++
++ WeatherData *AppModel::weather() const
++ {
++ return &(d->now);
++ }
++
++ QQmlListProperty<WeatherData> AppModel::forecast() const
++ {
++ return *(d->fcProp);
++ }
++
++ bool AppModel::ready() const
++ {
++ return d->ready;
++ }
++
++ bool AppModel::hasSource() const
++ {
++ return (d->src != NULL);
++ }
++
++ bool AppModel::useGps() const
++ {
++ return d->useGps;
++ }
++
++ void AppModel::setUseGps(bool value)
++ {
++ d->useGps = value;
++ if (value) {
++ d->city = "";
++ emit cityChanged();
++ emit weatherChanged();
++ // if we already have a valid GPS position, do not wait until it
++ // updates, but query the city immediately
++ if (d->coord.isValid())
++ requestWeatherByCoordinates();
++ }
++ emit useGpsChanged();
++ }
++
++ QString AppModel::city() const
++ {
++ return d->city;
++ }
++
++ void AppModel::setCity(const QString &value)
++ {
++ d->city = value;
++ emit cityChanged();
++ requestWeatherByCity();
++ }