Refactor app loading and window management (#609)
This commit is contained in:
committed by
GitHub
parent
dc3f6104b8
commit
37c507544b
@@ -8,6 +8,8 @@ list(APPEND REQUIRES_LIST
|
||||
TactilityKernel
|
||||
TactilityFreeRtos
|
||||
lvgl-module
|
||||
lvgl-window-manager-module
|
||||
app-module
|
||||
crypt-module
|
||||
gps-module
|
||||
gps-generic-module
|
||||
@@ -20,6 +22,7 @@ list(APPEND REQUIRES_LIST
|
||||
if (DEFINED ENV{ESP_IDF_VERSION})
|
||||
|
||||
list(APPEND REQUIRES_LIST
|
||||
app-esp32-module
|
||||
platform-esp32
|
||||
driver
|
||||
elf_loader
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* @brief key-value storage for general purpose.
|
||||
* Maps strings on a fixed set of data types.
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace tt {
|
||||
|
||||
/**
|
||||
* A dictionary that maps keys (strings) onto several atomary types.
|
||||
*/
|
||||
class Bundle final {
|
||||
|
||||
typedef uint32_t Hash;
|
||||
|
||||
enum class Type {
|
||||
Bool,
|
||||
Int32,
|
||||
Int64,
|
||||
String,
|
||||
};
|
||||
|
||||
typedef struct {
|
||||
Type type;
|
||||
union {
|
||||
bool value_bool;
|
||||
int32_t value_int32;
|
||||
int64_t value_int64;
|
||||
};
|
||||
std::string value_string;
|
||||
} Value;
|
||||
|
||||
std::unordered_map<std::string, Value> entries;
|
||||
|
||||
public:
|
||||
|
||||
Bundle() = default;
|
||||
|
||||
Bundle(const Bundle& bundle) {
|
||||
this->entries = bundle.entries;
|
||||
}
|
||||
|
||||
bool getBool(const std::string& key) const;
|
||||
int32_t getInt32(const std::string& key) const;
|
||||
int64_t getInt64(const std::string& key) const;
|
||||
std::string getString(const std::string& key) const;
|
||||
|
||||
bool hasBool(const std::string& key) const;
|
||||
bool hasInt32(const std::string& key) const;
|
||||
bool hasInt64(const std::string& key) const;
|
||||
bool hasString(const std::string& key) const;
|
||||
|
||||
bool optBool(const std::string& key, bool& out) const;
|
||||
bool optInt32(const std::string& key, int32_t& out) const;
|
||||
bool optInt64(const std::string& key, int64_t& out) const;
|
||||
bool optString(const std::string& key, std::string& out) const;
|
||||
|
||||
void putBool(const std::string& key, bool value);
|
||||
void putInt32(const std::string& key, int32_t value);
|
||||
void putInt64(const std::string& key, int64_t value);
|
||||
void putString(const std::string& key, const std::string& value);
|
||||
};
|
||||
|
||||
} // namespace
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* DEPRECATED: Use TactilityKernels' tactility/paths.h
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
#include <tactility/filesystem/file_system.h>
|
||||
|
||||
|
||||
namespace tt {
|
||||
|
||||
bool findFirstMountedSdCardPath(std::string& path);
|
||||
@@ -1,41 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace tt {
|
||||
|
||||
/**
|
||||
* Settings that persist on NVS flash for ESP32.
|
||||
* On simulator, the settings are only in-memory.
|
||||
*
|
||||
* Note that on ESP32, there are limitations:
|
||||
* - namespace name is limited by NVS_NS_NAME_MAX_SIZE (generally 16 characters)
|
||||
* - key is limited by NVS_KEY_NAME_MAX_SIZE (generally 16 characters)
|
||||
*/
|
||||
class Preferences {
|
||||
|
||||
const char* namespace_;
|
||||
|
||||
public:
|
||||
explicit Preferences(const char* namespace_) {
|
||||
this->namespace_ = namespace_;
|
||||
}
|
||||
|
||||
bool hasBool(const std::string& key) const;
|
||||
bool hasInt32(const std::string& key) const;
|
||||
bool hasInt64(const std::string& key) const;
|
||||
bool hasString(const std::string& key) const;
|
||||
|
||||
bool optBool(const std::string& key, bool& out) const;
|
||||
bool optInt32(const std::string& key, int32_t& out) const;
|
||||
bool optInt64(const std::string& key, int64_t& out) const;
|
||||
bool optString(const std::string& key, std::string& out) const;
|
||||
|
||||
void putBool(const std::string& key, bool value);
|
||||
void putInt32(const std::string& key, int32_t value);
|
||||
void putInt64(const std::string& key, int64_t value);
|
||||
void putString(const std::string& key, const std::string& value);
|
||||
};
|
||||
|
||||
} // namespace
|
||||
@@ -3,7 +3,6 @@
|
||||
#include <tactility/concurrent/dispatcher.h>
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/module.h>
|
||||
#include <Tactility/app/AppManifest.h>
|
||||
|
||||
#include <functional>
|
||||
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "Tactility/app/AppContext.h"
|
||||
|
||||
#include <Tactility/Bundle.h>
|
||||
#include <Tactility/Mutex.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
// Forward declarations
|
||||
typedef struct _lv_obj_t lv_obj_t;
|
||||
|
||||
namespace tt::app {
|
||||
|
||||
// Forward declarations
|
||||
class AppContext;
|
||||
enum class Result;
|
||||
|
||||
typedef unsigned int LaunchId;
|
||||
|
||||
class App {
|
||||
|
||||
Mutex mutex;
|
||||
|
||||
struct ResultHolder {
|
||||
Result result;
|
||||
std::unique_ptr<Bundle> resultData;
|
||||
|
||||
explicit ResultHolder(Result result) : result(result), resultData(nullptr) {}
|
||||
|
||||
ResultHolder(Result result, std::unique_ptr<Bundle> resultData) :
|
||||
result(result),
|
||||
resultData(std::move(resultData)) {}
|
||||
};
|
||||
|
||||
std::unique_ptr<ResultHolder> resultHolder;
|
||||
|
||||
public:
|
||||
|
||||
App() = default;
|
||||
virtual ~App() = default;
|
||||
|
||||
virtual void onCreate(AppContext& appContext) {}
|
||||
virtual void onDestroy(AppContext& appContext) {}
|
||||
virtual void onShow(AppContext& appContext, lv_obj_t* parent) {}
|
||||
virtual void onHide(AppContext& appContext) {}
|
||||
/** resultData could be null */
|
||||
virtual void onResult(AppContext& appContext, LaunchId launchId, Result result, std::unique_ptr<Bundle> resultData) {}
|
||||
|
||||
Mutex& getMutex() { return mutex; }
|
||||
|
||||
bool hasResult() const { return resultHolder != nullptr; }
|
||||
|
||||
void setResult(Result result, std::unique_ptr<Bundle> resultData = nullptr) {
|
||||
auto lock = getMutex().asScopedLock();
|
||||
lock.lock();
|
||||
resultHolder = std::make_unique<ResultHolder>(result, std::move(resultData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Used by system to extract the result data when this application is finished.
|
||||
* Note that this removes the data from the class!
|
||||
*/
|
||||
bool moveResult(Result& outResult, std::unique_ptr<Bundle>& outBundle) {
|
||||
auto lock = getMutex().asScopedLock();
|
||||
lock.lock();
|
||||
|
||||
if (resultHolder == nullptr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
outResult = resultHolder->result;
|
||||
outBundle = std::move(resultHolder->resultData);
|
||||
resultHolder = nullptr;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
template<typename T>
|
||||
std::shared_ptr<App> create() { return std::shared_ptr<T>(new T); }
|
||||
|
||||
/**
|
||||
* @brief Start an app
|
||||
* @param[in] id application name or id
|
||||
* @param[in] parameters optional parameters to pass onto the application. can be nullptr.
|
||||
*/
|
||||
LaunchId start(const std::string& id, std::shared_ptr<const Bundle> parameters = nullptr);
|
||||
|
||||
/** @brief Stop the currently showing app. Show the previous app if any app was still running. */
|
||||
void stop();
|
||||
|
||||
/** @brief Stop a specific app and any apps it might have launched on the stack.
|
||||
* @param[in] id the app id
|
||||
*/
|
||||
void stop(const std::string& id);
|
||||
|
||||
/** @brief Stop all app instances that match with this identifier and also stop the apps they started.
|
||||
* @warning onResult() will only be called for the resulting app that gets shown (if any)
|
||||
* @param[in] id the id of the app to stop
|
||||
*/
|
||||
void stopAll(const std::string& id);
|
||||
|
||||
/** @return true if the app is running somewhere in the app stack (doesn't have to be the top-most app) */
|
||||
bool isRunning(const std::string& id);
|
||||
|
||||
/** @return the currently running app context (it is only ever null before the splash screen is shown) */
|
||||
std::shared_ptr<AppContext> getCurrentAppContext();
|
||||
|
||||
/** @return the currently running app (it is only ever null before the splash screen is shown) */
|
||||
std::shared_ptr<App> getCurrentApp();
|
||||
|
||||
bool install(const std::string& path);
|
||||
|
||||
bool uninstall(const std::string& appId);
|
||||
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/Bundle.h>
|
||||
#include <memory>
|
||||
|
||||
namespace tt::app {
|
||||
|
||||
// Forward declarations
|
||||
class App;
|
||||
class AppPaths;
|
||||
struct AppManifest;
|
||||
enum class Result;
|
||||
|
||||
typedef union {
|
||||
struct {
|
||||
bool hideStatusbar : 1;
|
||||
};
|
||||
unsigned char flags;
|
||||
} Flags;
|
||||
|
||||
/**
|
||||
* The public representation of an application instance.
|
||||
* @warning Do not store references or pointers to these! You can retrieve them via the service registry.
|
||||
*/
|
||||
class AppContext {
|
||||
|
||||
protected:
|
||||
|
||||
virtual ~AppContext() = default;
|
||||
|
||||
public:
|
||||
|
||||
virtual const AppManifest& getManifest() const = 0;
|
||||
virtual std::shared_ptr<const Bundle> getParameters() const = 0;
|
||||
virtual std::unique_ptr<AppPaths> getPaths() const = 0;
|
||||
|
||||
virtual std::shared_ptr<App> getApp() const = 0;
|
||||
};
|
||||
|
||||
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/app/AppRegistration.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace tt::app {
|
||||
|
||||
class App;
|
||||
class AppContext;
|
||||
|
||||
/** Application types */
|
||||
enum class Category {
|
||||
/** Standard apps, provided by the system. */
|
||||
System,
|
||||
/** The apps that are launched/shown by the Settings app. The Settings app itself is of type AppTypeSystem. */
|
||||
Settings,
|
||||
/** User-provided apps. */
|
||||
User
|
||||
};
|
||||
|
||||
/** Result status code for application result callback. */
|
||||
enum class Result {
|
||||
Ok = 0U,
|
||||
Cancelled = 1U,
|
||||
Error = 2U
|
||||
};
|
||||
|
||||
class Location {
|
||||
|
||||
std::string path;
|
||||
Location() = default;
|
||||
explicit Location(const std::string& path) : path(path) {}
|
||||
|
||||
public:
|
||||
|
||||
static Location internal() { return {}; }
|
||||
|
||||
static Location external(const std::string& path) {
|
||||
return Location(path);
|
||||
}
|
||||
|
||||
/** Internal apps are all apps that are part of the firmware release. */
|
||||
bool isInternal() const { return path.empty(); }
|
||||
|
||||
/**
|
||||
* External apps are all apps that are not part of the firmware release.
|
||||
* e.g. an application on the sd card or one that is installed in /data
|
||||
*/
|
||||
bool isExternal() const { return !path.empty(); }
|
||||
const std::string& getPath() const { return path; }
|
||||
};
|
||||
|
||||
typedef std::shared_ptr<App>(*CreateApp)();
|
||||
|
||||
struct AppManifest {
|
||||
|
||||
struct Flags {
|
||||
constexpr static uint32_t None = 0;
|
||||
/** Don't show the statusbar */
|
||||
constexpr static uint32_t HideStatusBar = 1 << 0;
|
||||
/** Hint to other systems to not show this app (e.g. in launcher or settings) */
|
||||
constexpr static uint32_t Hidden = 1 << 1;
|
||||
};
|
||||
|
||||
/** The SDK version that was used to compile this app. (e.g. "0.6.0") */
|
||||
std::string targetSdk = {};
|
||||
|
||||
/** Comma-separated list of platforms, e.g. "esp32,esp32s3" */
|
||||
std::string targetPlatforms = {};
|
||||
|
||||
/** The identifier by which the app is launched by the system and other apps. */
|
||||
std::string appId = {};
|
||||
|
||||
/** The user-readable name of the app. Used in UI. */
|
||||
std::string appName = {};
|
||||
|
||||
/** Optional icon. */
|
||||
std::string appIcon = {};
|
||||
|
||||
/** The version as it is displayed to the user (e.g. "1.2.0") */
|
||||
std::string appVersionName = {};
|
||||
|
||||
/** The technical version (must be incremented with new releases of the app */
|
||||
uint64_t appVersionCode = 0;
|
||||
|
||||
/** App category helps with listing apps in Launcher, app list or settings apps. */
|
||||
Category appCategory = Category::User;
|
||||
|
||||
/** Where the app is located */
|
||||
Location appLocation = Location::internal();
|
||||
|
||||
/** Controls various settings */
|
||||
uint16_t appFlags = Flags::None;
|
||||
|
||||
/** Create the instance of the app */
|
||||
CreateApp createApp = nullptr;
|
||||
};
|
||||
|
||||
struct {
|
||||
bool operator()(const std::shared_ptr<AppManifest>& left, const std::shared_ptr<AppManifest>& right) const { return left->appName < right->appName; }
|
||||
} SortAppManifestByName;
|
||||
|
||||
} // namespace
|
||||
@@ -1,47 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <memory>
|
||||
|
||||
namespace tt::app {
|
||||
|
||||
// Forward declarations
|
||||
class AppManifest;
|
||||
|
||||
class AppPaths {
|
||||
|
||||
const AppManifest& manifest;
|
||||
|
||||
public:
|
||||
|
||||
explicit AppPaths(const AppManifest& manifest) : manifest(manifest) {}
|
||||
|
||||
/**
|
||||
* The user data directory is intended to survive OS upgrades.
|
||||
* The path will not end with a "/".
|
||||
*/
|
||||
std::string getUserDataPath() const;
|
||||
|
||||
/**
|
||||
* The user data directory is intended to survive OS upgrades.
|
||||
* Configuration data should be stored here.
|
||||
* @param[in] childPath the path without a "/" prefix
|
||||
*/
|
||||
std::string getUserDataPath(const std::string& childPath) const;
|
||||
|
||||
/**
|
||||
* You should not store configuration data here.
|
||||
* The path will not end with a "/".
|
||||
* This is mainly used for core apps (system/boot/settings type).
|
||||
*/
|
||||
std::string getAssetsPath() const;
|
||||
|
||||
/**
|
||||
* You should not store configuration data here.
|
||||
* This is mainly used for core apps (system/boot/settings type).
|
||||
* @param[in] childPath the path without a "/" prefix
|
||||
*/
|
||||
std::string getAssetsPath(const std::string& childPath) const;
|
||||
};
|
||||
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "App.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace tt::app {
|
||||
|
||||
struct AppManifest;
|
||||
|
||||
/** Register an application with its manifest */
|
||||
void addAppManifest(const AppManifest& manifest);
|
||||
|
||||
/** Remove an app from the registry */
|
||||
bool removeAppManifest(const std::string& id);
|
||||
|
||||
/** Find an application manifest by its id
|
||||
* @param[in] id the manifest id
|
||||
* @return the application manifest if it was found
|
||||
*/
|
||||
std::shared_ptr<AppManifest> findAppManifestById(const std::string& id);
|
||||
|
||||
/** @return a list of all registered apps. This includes user and system apps. */
|
||||
std::vector<std::shared_ptr<AppManifest>> getAppManifests();
|
||||
|
||||
} // namespace
|
||||
@@ -1,36 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "AppManifest.h"
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
|
||||
namespace tt::app {
|
||||
|
||||
typedef void* (*CreateData)();
|
||||
typedef void (*DestroyData)(void* data);
|
||||
/** data is nullable */
|
||||
typedef void (*OnCreate)(void* appContext, void* data);
|
||||
/** data is nullable */
|
||||
typedef void (*OnDestroy)(void* appContext, void* data);
|
||||
/** data is nullable */
|
||||
typedef void (*OnShow)(void* appContext, void* data, lv_obj_t* parent);
|
||||
/** data is nullable */
|
||||
typedef void (*OnHide)(void* appContext, void* data);
|
||||
/** data is nullable, resultData is nullable. */
|
||||
typedef void (*OnResult)(void* appContext, void* data, LaunchId launchId, Result result, Bundle* resultData);
|
||||
|
||||
/** All fields are nullable */
|
||||
void setElfAppParameters(
|
||||
CreateData createData,
|
||||
DestroyData destroyData,
|
||||
OnCreate onCreate,
|
||||
OnDestroy onDestroy,
|
||||
OnShow onShow,
|
||||
OnHide onHide,
|
||||
OnResult onResult
|
||||
);
|
||||
|
||||
std::shared_ptr<App> createElfApp(const std::shared_ptr<AppManifest>& manifest);
|
||||
|
||||
}
|
||||
#endif // ESP_PLATFORM
|
||||
@@ -1,49 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/Bundle.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <Tactility/app/App.h>
|
||||
|
||||
/**
|
||||
* Start the app by its ID and provide:
|
||||
* - a title
|
||||
* - a text
|
||||
* - 0, 1 or more buttons
|
||||
* Show a dialog with a title, a message and 0, 1 or more buttons.
|
||||
*/
|
||||
namespace tt::app::alertdialog {
|
||||
|
||||
/**
|
||||
* Show a dialog with the provided title, message and 0, 1 or more buttons.
|
||||
* @param[in] title the title to show in the toolbar
|
||||
* @param[in] message the message to display
|
||||
* @param[in] buttonLabels the buttons to show
|
||||
* @return the launch id
|
||||
* Show a dialog with the provided title, message and buttons, as a modal child of
|
||||
* @a callerAppInstanceId (a new-model app - see app/manager.h). The caller receives the
|
||||
* result as an APP_EVENT_RESULT in its own event loop: result is the pressed button's index
|
||||
* (>= 0), or a value not matching any button (currently always 1) if the dialog was dismissed
|
||||
* without a button press. No result_bundle. The caller is responsible for calling
|
||||
* app_manager_stop() on the returned instance id once it has handled the result.
|
||||
* @return the new dialog's app instance id
|
||||
*/
|
||||
LaunchId start(const std::string& title, const std::string& message, const std::vector<std::string>& buttonLabels);
|
||||
/**
|
||||
* Show a dialog with the provided title, message and 0, 1 or more buttons.
|
||||
* @param[in] title the title to show in the toolbar
|
||||
* @param[in] message the message to display
|
||||
* @param[in] buttonLabels the buttons to show
|
||||
* @return the launch id
|
||||
*/
|
||||
LaunchId start(const std::string& title, const std::string& message, const std::vector<const char*>& buttonLabels);
|
||||
uint32_t start(uint32_t callerAppInstanceId, const std::string& title, const std::string& message, const std::vector<std::string>& buttonLabels);
|
||||
|
||||
/**
|
||||
* Show a dialog with the provided title, message and an OK button
|
||||
* @param[in] title the title to show in the toolbar
|
||||
* @param[in] message the message to display
|
||||
* @return the launch id
|
||||
*/
|
||||
LaunchId start(const std::string& title, const std::string& message);
|
||||
|
||||
/**
|
||||
* Get the index of the button that the user selected.
|
||||
*
|
||||
* @return a value greater than 0 when a selection was done, or -1 when the app was closed clicking one of the selection buttons.
|
||||
*/
|
||||
int32_t getResultIndex(const Bundle& bundle);
|
||||
/** @copydoc start(uint32_t, const std::string&, const std::string&, const std::vector<std::string>&)
|
||||
* Shows a single "OK" button. */
|
||||
uint32_t start(uint32_t callerAppInstanceId, const std::string& title, const std::string& message);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/app/App.h>
|
||||
#include <cstdint>
|
||||
|
||||
namespace tt::app::btmanage {
|
||||
|
||||
LaunchId start();
|
||||
uint32_t start();
|
||||
|
||||
} // namespace tt::app::btmanage
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/app/App.h>
|
||||
#include <Tactility/Bundle.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace tt::app::fileselection {
|
||||
|
||||
/**
|
||||
* Show a file selection dialog that allows the user to select an existing file.
|
||||
* This app returns the absolute file path as a result.
|
||||
* Show a file selection dialog that allows the user to select an existing file, as a modal
|
||||
* child of @a callerAppInstanceId (see app_manager_start_for_result()). Result (0 = Ok,
|
||||
* 1 = Cancelled) is delivered back via APP_EVENT_RESULT once this app's thread exits - call
|
||||
* getLastPath() right after receiving it, on result == 0. The caller must call
|
||||
* app_manager_stop() on the returned instance id once that event arrives, to fully reap this
|
||||
* instance.
|
||||
* @return the new app instance id
|
||||
*/
|
||||
LaunchId startForExistingFile();
|
||||
uint32_t startForExistingFile(uint32_t callerAppInstanceId);
|
||||
|
||||
/**
|
||||
* Show a file selection dialog that allows the user to select a new or existing file.
|
||||
* This app returns the absolute file path as a result.
|
||||
* Same as startForExistingFile(), but also allows picking a path that doesn't exist yet (for
|
||||
* "save as"-style flows).
|
||||
*/
|
||||
LaunchId startForExistingOrNewFile();
|
||||
uint32_t startForExistingOrNewFile(uint32_t callerAppInstanceId);
|
||||
|
||||
/**
|
||||
* @param bundle the result bundle of an app
|
||||
* @return the path from the bundle, or empty string if none is present
|
||||
*/
|
||||
std::string getResultPath(const Bundle& bundle);
|
||||
/** @return the path picked by the last FileSelection dialog that closed with result == Ok. Only
|
||||
* one dialog is expected to be open at a time. */
|
||||
std::string getLastPath();
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/app/App.h>
|
||||
#include <string>
|
||||
|
||||
namespace tt::app::imageviewer {
|
||||
|
||||
LaunchId start(const std::string& file);
|
||||
/**
|
||||
* Show a full-screen viewer for a single image file. Fire-and-forget: doesn't report any result
|
||||
* back to the caller.
|
||||
* @param file the path to the image file to display
|
||||
*/
|
||||
void start(const std::string& file);
|
||||
|
||||
}
|
||||
@@ -1,21 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/app/App.h>
|
||||
#include <Tactility/Bundle.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
/**
|
||||
* Start the app by its ID and provide:
|
||||
* - a title
|
||||
* - a text
|
||||
* Show a dialog with a title, a message and a text field.
|
||||
*/
|
||||
namespace tt::app::inputdialog {
|
||||
|
||||
LaunchId start(const std::string& title, const std::string& message, const std::string& prefilled = "");
|
||||
/**
|
||||
* Show a dialog with the provided title, message and prefilled text, as a modal child of
|
||||
* @a callerAppInstanceId (a new-model app - see app/manager.h). The caller receives the result
|
||||
* as an APP_EVENT_RESULT in its own event loop: 0 = OK (call getLastText() for the entered
|
||||
* text), 1 = Cancelled or dismissed without a press. The caller is responsible for calling
|
||||
* app_manager_stop() on the returned instance id once it has handled the result.
|
||||
* @return the new dialog's app instance id
|
||||
*/
|
||||
uint32_t start(uint32_t callerAppInstanceId, const std::string& title, const std::string& message, const std::string& prefilled = "");
|
||||
|
||||
/**
|
||||
* @return the text that was in the field when OK was pressed, or otherwise empty string
|
||||
* @return the text entered the last time any InputDialog instance was closed with OK. Only one
|
||||
* dialog is expected to be open at a time - call this right after receiving its
|
||||
* APP_EVENT_RESULT with result == 0.
|
||||
*/
|
||||
std::string getResult(const Bundle& bundle);
|
||||
std::string getLastText();
|
||||
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/app/App.h>
|
||||
#include <string>
|
||||
|
||||
namespace tt::app::notes {
|
||||
|
||||
/**
|
||||
* Start the notes app with the specified text file.
|
||||
* @param[in] filePath the path to the text file to open
|
||||
* @return the launch id
|
||||
*/
|
||||
LaunchId start(const std::string& filePath);
|
||||
void start(const std::string& filePath);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/app/App.h>
|
||||
#include <Tactility/Bundle.h>
|
||||
#include "app/instance.h"
|
||||
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
/**
|
||||
* Start the app by its ID and provide:
|
||||
* - an optional title
|
||||
* - 2 or more items
|
||||
*
|
||||
* If you provide 0 items, the app will auto-close.
|
||||
* If you provide 1 item, the app will auto-close with result index 0
|
||||
* Show a dialog with a title and a list of selectable items.
|
||||
*/
|
||||
namespace tt::app::selectiondialog {
|
||||
|
||||
LaunchId start(const std::string& title, const std::vector<std::string>& items);
|
||||
|
||||
/**
|
||||
* Get the index of the item that the user selected.
|
||||
*
|
||||
* @return a value greater than 0 when a selection was done, or -1 when the app was closed without selecting an item.
|
||||
* Show a selection dialog with the provided title and items, as a modal child of
|
||||
* @a callerAppInstanceId (a new-model app - see app/manager.h). The caller receives the
|
||||
* result as an APP_EVENT_RESULT in its own event loop: result is the selected item's index
|
||||
* (>= 0), -1 if 0 items were provided (an error - the dialog auto-closes without showing
|
||||
* anything), or a value not matching any item (currently always 1) if the dialog was
|
||||
* dismissed without a selection. No result_bundle. If exactly 1 item is provided, the dialog
|
||||
* auto-closes with result index 0 without showing anything. The caller is responsible for
|
||||
* calling app_manager_stop() on the returned instance id once it has handled the result.
|
||||
* @return the new dialog's app instance id
|
||||
*/
|
||||
int32_t getResultIndex(const Bundle& bundle);
|
||||
AppInstanceId start(AppInstanceId callerAppInstanceId, const std::string& title, const std::vector<std::string>& items);
|
||||
|
||||
}
|
||||
|
||||
@@ -6,11 +6,16 @@
|
||||
|
||||
#if defined(CONFIG_TT_TOUCH_CALIBRATION_SUPPORTED)
|
||||
|
||||
#include <Tactility/app/App.h>
|
||||
#include <cstdint>
|
||||
|
||||
namespace tt::app::touchcalibration {
|
||||
|
||||
LaunchId start();
|
||||
/**
|
||||
* Starts calibration as a modal child of @a callerAppInstanceId. Result (Ok=0/Error=2, no
|
||||
* bundle) is delivered as APP_EVENT_RESULT once the user dismisses the outcome screen.
|
||||
* @return the new app instance id
|
||||
*/
|
||||
uint32_t start(uint32_t callerAppInstanceId);
|
||||
|
||||
} // namespace tt::app::touchcalibration
|
||||
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/app/App.h>
|
||||
#include <cstdint>
|
||||
|
||||
namespace tt::app::wifimanage {
|
||||
|
||||
LaunchId start();
|
||||
/**
|
||||
* Starts as a modal child of @a callerAppInstanceId (see app_manager_start_for_result()) - an
|
||||
* APP_EVENT_RESULT is delivered back once the user closes this screen (default Cancelled/no
|
||||
* bundle if never explicitly set - callers that just want a "the wifi step is done" signal, like
|
||||
* Setup, can ignore the actual result value).
|
||||
* @return the new app instance id
|
||||
*/
|
||||
uint32_t start(uint32_t callerAppInstanceId);
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/app/AppContext.h>
|
||||
|
||||
#include <lvgl.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace tt::lvgl {
|
||||
|
||||
constexpr auto STATUSBAR_ICON_LIMIT = 8;
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "../app/AppContext.h"
|
||||
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
|
||||
namespace tt::lvgl {
|
||||
|
||||
/** Create a toolbar widget that shows the app name as title */
|
||||
lv_obj_t* toolbar_create(lv_obj_t* parent, const app::AppContext& app);
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/DispatcherThread.h>
|
||||
#include <Tactility/Bundle.h>
|
||||
#include <Tactility/PubSub.h>
|
||||
#include <Tactility/RecursiveMutex.h>
|
||||
#include <Tactility/app/AppInstance.h>
|
||||
#include <Tactility/app/AppManifest.h>
|
||||
#include <Tactility/service/Service.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace tt::service::loader {
|
||||
|
||||
|
||||
class LoaderService final : public Service {
|
||||
|
||||
public:
|
||||
|
||||
enum class Event {
|
||||
ApplicationStarted,
|
||||
ApplicationShowing,
|
||||
ApplicationHiding,
|
||||
ApplicationStopped
|
||||
};
|
||||
|
||||
private:
|
||||
|
||||
std::shared_ptr<PubSub<Event>> pubsubExternal = std::make_shared<PubSub<Event>>();
|
||||
RecursiveMutex mutex;
|
||||
std::vector<std::shared_ptr<app::AppInstance>> appStack;
|
||||
app::LaunchId nextLaunchId = 0;
|
||||
|
||||
/** The dispatcher thread needs a callstack large enough to accommodate all the dispatched methods.
|
||||
* This includes full LVGL redraw via Gui::redraw()
|
||||
*/
|
||||
std::unique_ptr<DispatcherThread> dispatcherThread = std::make_unique<DispatcherThread>("loader_dispatcher", 6144); // Files app requires ~5k
|
||||
|
||||
void onStartAppMessage(const std::string& id, app::LaunchId launchId, std::shared_ptr<const Bundle> parameters);
|
||||
|
||||
void onStopTopAppMessage(const std::string& id);
|
||||
|
||||
void onStopAllAppMessage(const std::string& id);
|
||||
|
||||
void transitionAppToState(const std::shared_ptr<app::AppInstance>& app, app::State state);
|
||||
|
||||
int findAppInStack(const std::string& id) const;
|
||||
|
||||
bool onStart(ServiceContext& service) override {
|
||||
dispatcherThread->start();
|
||||
return true;
|
||||
}
|
||||
|
||||
void onStop(ServiceContext& service) override {
|
||||
// Send stop signal to thread and wait for thread to finish
|
||||
mutex.withLock([this] {
|
||||
dispatcherThread->stop();
|
||||
});
|
||||
}
|
||||
|
||||
public:
|
||||
/**
|
||||
* @brief Start an app given an app id and an optional bundle with parameters
|
||||
* @param id the app identifier
|
||||
* @param parameters optional parameter bundle (nullable)
|
||||
* @return the launch id
|
||||
*/
|
||||
app::LaunchId start(const std::string& id, std::shared_ptr<const Bundle> parameters);
|
||||
|
||||
/**
|
||||
* @brief Stops the top-most app (the one that is currently active shown to the user
|
||||
* @warning Avoid calling this directly and use stopTop(id) instead
|
||||
*/
|
||||
void stopTop();
|
||||
|
||||
/**
|
||||
* @brief Stops the top-most app if the id is still matching by the time the stop event arrives.
|
||||
* @param id the id of the app to stop
|
||||
*/
|
||||
void stopTop(const std::string& id);
|
||||
|
||||
/**
|
||||
* @brief Stops all apps with the provided id and any apps that were pushed on top of the stack after the original app was started.
|
||||
* @param id the id of the app to stop
|
||||
*/
|
||||
void stopAll(const std::string& id);
|
||||
|
||||
/** @return the AppContext of the top-most application, or nullptr if no app is running. */
|
||||
std::shared_ptr<app::AppContext> getCurrentAppContext();
|
||||
|
||||
/** @return true if the app is running anywhere in the app stack (the app does not have to be the top-most one for this to return true) */
|
||||
bool isRunning(const std::string& id) const;
|
||||
|
||||
/** @return the PubSub object that is responsible for event publishing */
|
||||
std::shared_ptr<PubSub<Event>> getPubsub() const { return pubsubExternal; }
|
||||
};
|
||||
|
||||
/** return the service or nullptr if it's not running */
|
||||
std::shared_ptr<LoaderService> findLoaderService();
|
||||
|
||||
} // namespace
|
||||
@@ -1,98 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/app/AppContext.h>
|
||||
#include <Tactility/app/AppManifest.h>
|
||||
#include <Tactility/app/ElfApp.h>
|
||||
|
||||
#include <Tactility/Bundle.h>
|
||||
#include <tactility/check.h>
|
||||
#include <tactility/log.h>
|
||||
#include <Tactility/Mutex.h>
|
||||
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
|
||||
namespace tt::app {
|
||||
|
||||
enum class State {
|
||||
Initial, // AppInstance was created, but the state hasn't advanced yet
|
||||
Created, // App was placed into memory
|
||||
Showing, // App view was created
|
||||
Hiding, // App view was destroyed
|
||||
Destroyed // App was removed from memory
|
||||
};
|
||||
|
||||
/**
|
||||
* Thread-safe app instance.
|
||||
*/
|
||||
class AppInstance : public AppContext {
|
||||
|
||||
Mutex mutex;
|
||||
const std::shared_ptr<AppManifest> manifest;
|
||||
State state = State::Initial;
|
||||
LaunchId launchId;
|
||||
Flags flags = { .hideStatusbar = true };
|
||||
/** @brief Optional parameters to start the app with
|
||||
* When these are stored in the app struct, the struct takes ownership.
|
||||
* Do not mutate after app creation.
|
||||
*/
|
||||
std::shared_ptr<const Bundle> parameters;
|
||||
|
||||
std::shared_ptr<App> app;
|
||||
|
||||
static std::shared_ptr<App> createApp(
|
||||
const std::shared_ptr<AppManifest>& manifest
|
||||
) {
|
||||
if (manifest->appLocation.isInternal()) {
|
||||
assert(manifest->createApp != nullptr);
|
||||
return manifest->createApp();
|
||||
} else if (manifest->appLocation.isExternal()) {
|
||||
if (manifest->createApp != nullptr) {
|
||||
LOG_W("AppInstance", "Manifest specifies createApp, but this is not used with external apps");
|
||||
}
|
||||
#ifdef ESP_PLATFORM
|
||||
return createElfApp(manifest);
|
||||
#else
|
||||
check(false, "not supported");
|
||||
#endif
|
||||
} else {
|
||||
check(false, "not implemented");
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
|
||||
explicit AppInstance(const std::shared_ptr<AppManifest>& manifest, LaunchId launchId) :
|
||||
manifest(manifest),
|
||||
launchId(launchId),
|
||||
app(createApp(manifest))
|
||||
{}
|
||||
|
||||
AppInstance(const std::shared_ptr<AppManifest>& manifest, LaunchId launchId, std::shared_ptr<const Bundle> parameters) :
|
||||
manifest(manifest),
|
||||
launchId(launchId),
|
||||
parameters(std::move(parameters)),
|
||||
app(createApp(manifest))
|
||||
{}
|
||||
|
||||
~AppInstance() override = default;
|
||||
|
||||
LaunchId getLaunchId() const { return launchId; }
|
||||
|
||||
void setState(State state);
|
||||
State getState() const;
|
||||
|
||||
const AppManifest& getManifest() const override;
|
||||
|
||||
Flags getFlags() const;
|
||||
void setFlags(Flags flags);
|
||||
Flags& mutableFlags() { return flags; } // TODO: locking mechanism
|
||||
|
||||
std::shared_ptr<const Bundle> getParameters() const override;
|
||||
|
||||
std::unique_ptr<AppPaths> getPaths() const override;
|
||||
|
||||
std::shared_ptr<App> getApp() const override { return app; }
|
||||
};
|
||||
|
||||
} // namespace
|
||||
@@ -1,14 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/app/AppManifest.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace tt::app {
|
||||
|
||||
bool isValidId(const std::string& id);
|
||||
|
||||
/** Parses a manifest.properties file, auto-detecting the V1 (sectioned) or V2 (flat) format from its first line. */
|
||||
bool parseManifest(const std::string& filePath, AppManifest& manifest);
|
||||
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/app/AppManifest.h>
|
||||
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
namespace tt::app {
|
||||
|
||||
bool getValueFromManifest(const std::map<std::string, std::string>& map, const std::string& key, std::string& output);
|
||||
|
||||
bool isValidManifestVersion(const std::string& version);
|
||||
bool isValidAppVersionName(const std::string& version);
|
||||
bool isValidAppVersionCode(const std::string& version);
|
||||
bool isValidName(const std::string& name);
|
||||
|
||||
/** Parses a V1 (sectioned INI, e.g. "[app]versionName=...") manifest map. */
|
||||
bool parseManifestV1(const std::map<std::string, std::string>& map, AppManifest& manifest);
|
||||
|
||||
/** Parses a V2 (flat dot-notation, e.g. "app.version.name=...") manifest map. */
|
||||
bool parseManifestV2(const std::map<std::string, std::string>& map, AppManifest& manifest);
|
||||
|
||||
}
|
||||
@@ -5,11 +5,13 @@
|
||||
|
||||
namespace tt::app::btmanage {
|
||||
|
||||
typedef void (*OnBtToggled)(bool enable);
|
||||
typedef void (*OnScanToggled)(bool enable);
|
||||
// `context` is this app instance's Context* (see BtManagePrivate.h) - the new app-module has no
|
||||
// global "current app" accessor, so callbacks need it threaded through explicitly.
|
||||
typedef void (*OnBtToggled)(void* context, bool enable);
|
||||
typedef void (*OnScanToggled)(void* context, bool enable);
|
||||
typedef void (*OnConnectPeer)(const std::array<uint8_t, 6>& addr, int profileId);
|
||||
typedef void (*OnDisconnectPeer)(const std::array<uint8_t, 6>& addr, int profileId);
|
||||
typedef void (*OnPairPeer)(const std::array<uint8_t, 6>& addr);
|
||||
typedef void (*OnPairPeer)(void* context, const std::array<uint8_t, 6>& addr);
|
||||
typedef void (*OnForgetPeer)(const std::array<uint8_t, 6>& addr);
|
||||
|
||||
struct Bindings {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
#include "./View.h"
|
||||
#include "./State.h"
|
||||
|
||||
#include <Tactility/app/App.h>
|
||||
#include <Tactility/Mutex.h>
|
||||
#include <Tactility/bluetooth/Bluetooth.h>
|
||||
#include <tactility/drivers/bluetooth.h>
|
||||
@@ -13,54 +12,37 @@
|
||||
|
||||
namespace tt::app::btmanage {
|
||||
|
||||
class BtManage final : public App {
|
||||
|
||||
struct Context {
|
||||
uint32_t appInstanceId;
|
||||
Mutex mutex;
|
||||
Bindings bindings = { };
|
||||
Bindings bindings {};
|
||||
State state;
|
||||
View view = View(&bindings, &state);
|
||||
bool isViewEnabled = false;
|
||||
Device* btDevice = nullptr;
|
||||
bool callbackRegistered = false;
|
||||
|
||||
// Bumped by onHide() to invalidate any BT event already dispatched to the main
|
||||
// task for this show/hide session (BtManage is reused across hide/show cycles -
|
||||
// e.g. launching BtPeerSettings pushes it on top and hides this instance without
|
||||
// destroying it). Kept in its own heap allocation, independent of BtManage's
|
||||
// lifetime, so a dispatched callback can check it without touching a possibly
|
||||
// already-destroyed `this`.
|
||||
// Bumped right before the BT event callback is unregistered at the end of appMain(), to
|
||||
// invalidate any BT event already dispatched to the main task for this instance. Kept in
|
||||
// its own heap allocation, independent of Context's (stack-local) lifetime, so a dispatched
|
||||
// callback can check it without touching a possibly already-destroyed Context.
|
||||
std::shared_ptr<std::atomic<int>> generation = std::make_shared<std::atomic<int>>(0);
|
||||
|
||||
public:
|
||||
|
||||
void onBtEvent(const struct BtEvent& event);
|
||||
|
||||
BtManage();
|
||||
|
||||
void lock();
|
||||
void unlock();
|
||||
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override;
|
||||
void onHide(AppContext& app) override;
|
||||
|
||||
Bindings& getBindings() { return bindings; }
|
||||
State& getState() { return state; }
|
||||
|
||||
void requestViewUpdate();
|
||||
|
||||
std::shared_ptr<std::atomic<int>> getGeneration() const { return generation; }
|
||||
|
||||
// Re-attempts registering the device event callback. Needed because the BLE driver
|
||||
// only allocates its callback list while the device is started/on: a registration
|
||||
// attempted while the radio is off silently no-ops, so this must be called again
|
||||
// right after a successful bluetooth::start(). Idempotent: no-ops if already
|
||||
// registered for this device, so it's safe to call from both onShow() and here.
|
||||
void registerDeviceCallback(Device* dev);
|
||||
|
||||
// Call after bluetooth::stop(): the driver frees its callback list on stop, so the
|
||||
// registration state must be cleared here too, without touching the (now-dangling)
|
||||
// driver-side list.
|
||||
void forgetCallbackRegistration();
|
||||
void lock() { mutex.lock(); }
|
||||
void unlock() { mutex.unlock(); }
|
||||
};
|
||||
|
||||
void onBtEvent(Context* ctx, const struct BtEvent& event);
|
||||
void requestViewUpdate(Context* ctx);
|
||||
|
||||
// Re-attempts registering the device event callback. Needed because the BLE driver only
|
||||
// allocates its callback list while the device is started/on: a registration attempted while
|
||||
// the radio is off silently no-ops, so this must be called again right after a successful
|
||||
// bluetooth::start(). Idempotent: no-ops if already registered for this device.
|
||||
void registerDeviceCallback(Context* ctx, Device* dev);
|
||||
|
||||
// Call after bluetooth::stop(): the driver frees its callback list on stop, so the
|
||||
// registration state must be cleared here too, without touching the (now-dangling) driver-side
|
||||
// list.
|
||||
void forgetCallbackRegistration(Context* ctx);
|
||||
|
||||
} // namespace tt::app::btmanage
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
#include "./Bindings.h"
|
||||
#include "./State.h"
|
||||
|
||||
#include <Tactility/app/AppContext.h>
|
||||
#include <Tactility/app/AppPaths.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <lvgl.h>
|
||||
|
||||
namespace tt::app::btmanage {
|
||||
@@ -14,7 +12,9 @@ class View final {
|
||||
|
||||
Bindings* bindings;
|
||||
State* state;
|
||||
std::unique_ptr<AppPaths> paths;
|
||||
// Passed through to onBtToggled/onScanToggled/onPairPeer via lv_obj user_data - see
|
||||
// Bindings.h. Set in init(), before any callback can fire.
|
||||
void* context = nullptr;
|
||||
lv_obj_t* root = nullptr;
|
||||
lv_obj_t* enable_switch = nullptr;
|
||||
lv_obj_t* enable_on_boot_switch = nullptr;
|
||||
@@ -34,7 +34,7 @@ public:
|
||||
|
||||
View(Bindings* bindings, State* state) : bindings(bindings), state(state) {}
|
||||
|
||||
void init(const AppContext& app, lv_obj_t* parent);
|
||||
void init(void* context, lv_obj_t* parent);
|
||||
void update();
|
||||
};
|
||||
|
||||
|
||||
@@ -10,37 +10,32 @@
|
||||
#include "ChatView.h"
|
||||
#include "ChatSettings.h"
|
||||
|
||||
#include <Tactility/app/App.h>
|
||||
#include <Tactility/service/espnow/EspNow.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace tt::app::chat {
|
||||
|
||||
class ChatApp final : public App {
|
||||
|
||||
// Replaces the old ChatApp (tt::app::App subclass) under the thread-per-app model. Declared
|
||||
// here (rather than local to ChatApp.cpp's anonymous namespace, as most converted apps do)
|
||||
// because ChatView - a separate translation unit - also needs to reference it.
|
||||
struct Context {
|
||||
uint32_t appInstanceId;
|
||||
ChatState state;
|
||||
ChatView view = ChatView(this, &state);
|
||||
service::espnow::ReceiverSubscription receiveSubscription = -1;
|
||||
ChatSettingsData settings;
|
||||
bool isFirstLaunch = false;
|
||||
|
||||
void onReceive(const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length);
|
||||
void enableEspNow();
|
||||
void disableEspNow();
|
||||
|
||||
public:
|
||||
void onCreate(AppContext& appContext) override;
|
||||
void onDestroy(AppContext& appContext) override;
|
||||
void onShow(AppContext& context, lv_obj_t* parent) override;
|
||||
|
||||
void sendMessage(const std::string& text);
|
||||
void applySettings(const std::string& nickname, const std::string& keyHex);
|
||||
void switchChannel(const std::string& chatChannel);
|
||||
|
||||
const ChatSettingsData& getSettings() const { return settings; }
|
||||
|
||||
~ChatApp() override = default;
|
||||
};
|
||||
|
||||
void enableEspNow(Context* ctx);
|
||||
void disableEspNow(Context* ctx);
|
||||
|
||||
void sendMessage(Context* ctx, const std::string& text);
|
||||
void applySettings(Context* ctx, const std::string& nickname, const std::string& keyHex);
|
||||
void switchChannel(Context* ctx, const std::string& chatChannel);
|
||||
|
||||
} // namespace tt::app::chat
|
||||
|
||||
#endif // CONFIG_SOC_WIFI_SUPPORTED || CONFIG_SLAVE_SOC_WIFI_SUPPORTED
|
||||
|
||||
@@ -9,18 +9,16 @@
|
||||
#include "ChatState.h"
|
||||
#include "ChatSettings.h"
|
||||
|
||||
#include <Tactility/app/AppContext.h>
|
||||
|
||||
#include <esp_now.h>
|
||||
#include <lvgl.h>
|
||||
|
||||
namespace tt::app::chat {
|
||||
|
||||
class ChatApp;
|
||||
struct Context;
|
||||
|
||||
class ChatView {
|
||||
|
||||
ChatApp* app;
|
||||
Context* app;
|
||||
ChatState* state;
|
||||
|
||||
lv_obj_t* toolbar = nullptr;
|
||||
@@ -45,6 +43,7 @@ class ChatView {
|
||||
|
||||
static void addMessageToList(lv_obj_t* msgList, const StoredMessage& msg);
|
||||
|
||||
static void onBackPressed(lv_event_t* e);
|
||||
static void onSendClicked(lv_event_t* e);
|
||||
static void onSettingsClicked(lv_event_t* e);
|
||||
static void onSettingsSave(lv_event_t* e);
|
||||
@@ -54,7 +53,7 @@ class ChatView {
|
||||
static void onChannelCancel(lv_event_t* e);
|
||||
|
||||
public:
|
||||
ChatView(ChatApp* app, ChatState* state) : app(app), state(state) {}
|
||||
ChatView(Context* app, ChatState* state) : app(app), state(state) {}
|
||||
~ChatView() = default;
|
||||
|
||||
ChatView(const ChatView&) = delete;
|
||||
@@ -62,7 +61,7 @@ public:
|
||||
ChatView(ChatView&&) = delete;
|
||||
ChatView& operator=(ChatView&&) = delete;
|
||||
|
||||
void init(AppContext& appContext, lv_obj_t* parent);
|
||||
void init(lv_obj_t* parent);
|
||||
|
||||
void displayMessage(const StoredMessage& msg);
|
||||
void refreshMessageList();
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
|
||||
namespace tt::app::development {
|
||||
|
||||
void start();
|
||||
|
||||
}
|
||||
|
||||
#endif // ESP_PLATFORM
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
#include "./State.h"
|
||||
|
||||
#include <Tactility/app/AppManifest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <lvgl.h>
|
||||
#include <memory>
|
||||
|
||||
@@ -11,7 +10,8 @@ namespace tt::app::files {
|
||||
|
||||
class View final {
|
||||
std::shared_ptr<State> state;
|
||||
|
||||
uint32_t appInstanceId = 0;
|
||||
|
||||
size_t current_start_index = 0;
|
||||
size_t last_loaded_index = 0;
|
||||
const size_t MAX_BATCH = 50;
|
||||
@@ -24,7 +24,7 @@ class View final {
|
||||
lv_obj_t* paste_button = nullptr;
|
||||
|
||||
std::string installAppPath = { 0 };
|
||||
LaunchId installAppLaunchId = 0;
|
||||
uint32_t installDialogId = 0;
|
||||
|
||||
void showActions();
|
||||
void showActionsForDirectory();
|
||||
@@ -39,9 +39,10 @@ public:
|
||||
|
||||
explicit View(const std::shared_ptr<State>& state) : state(state) {}
|
||||
|
||||
void init(const AppContext& appContext, lv_obj_t* parent);
|
||||
void init(uint32_t appInstanceId, lv_obj_t* parent);
|
||||
void update(size_t start_index = 0);
|
||||
|
||||
void onBackPressed();
|
||||
void onNavigateUpPressed();
|
||||
void onDirEntryPressed(uint32_t index);
|
||||
void onDirEntryLongPressed(int32_t index);
|
||||
@@ -54,8 +55,8 @@ public:
|
||||
void onPastePressed();
|
||||
void onEjectPressed();
|
||||
void onDirEntryListScrollBegin();
|
||||
void onResult(LaunchId launchId, Result result, std::unique_ptr<Bundle> bundle);
|
||||
void deinit(const AppContext& appContext);
|
||||
void onResult(uint32_t launchId, int32_t result);
|
||||
void deinit();
|
||||
|
||||
private:
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/Bundle.h>
|
||||
|
||||
namespace tt::app::fileselection {
|
||||
|
||||
enum class Mode {
|
||||
@@ -9,6 +7,4 @@ enum class Mode {
|
||||
ExistingOrNew = 1
|
||||
};
|
||||
|
||||
Mode getMode(const Bundle& bundle);
|
||||
|
||||
}
|
||||
|
||||
@@ -3,14 +3,13 @@
|
||||
#include "./State.h"
|
||||
#include "./FileSelectionPrivate.h"
|
||||
|
||||
#include <Tactility/app/AppManifest.h>
|
||||
|
||||
#include <lvgl.h>
|
||||
#include <memory>
|
||||
|
||||
namespace tt::app::fileselection {
|
||||
|
||||
class View final {
|
||||
uint32_t appInstanceId;
|
||||
std::shared_ptr<State> state;
|
||||
|
||||
lv_obj_t* dir_entry_list = nullptr;
|
||||
@@ -22,11 +21,15 @@ class View final {
|
||||
void onTapFile(const std::string&path, const std::string&filename);
|
||||
static void onSelectButtonPressed(lv_event_t* event);
|
||||
static void onPathTextChanged(lv_event_t* event);
|
||||
/** Emits an async APP_EVENT_CLOSE for appInstanceId - see FileSelection.cpp's appMain() for
|
||||
* why this indirection (rather than calling app_manager_stop() here) is required. */
|
||||
static void onBackPressedCallback(lv_event_t* event);
|
||||
void createDirEntryWidget(lv_obj_t* parent, dirent& dir_entry);
|
||||
|
||||
public:
|
||||
|
||||
explicit View(const std::shared_ptr<State>& state, std::function<void(const std::string& path)> onFileSelected) :
|
||||
explicit View(uint32_t appInstanceId, const std::shared_ptr<State>& state, std::function<void(const std::string& path)> onFileSelected) :
|
||||
appInstanceId(appInstanceId),
|
||||
state(state),
|
||||
on_file_selected(std::move(onFileSelected))
|
||||
{}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/app/App.h>
|
||||
#include <cstdint>
|
||||
|
||||
namespace tt::app::i2cscanner {
|
||||
|
||||
LaunchId start();
|
||||
uint32_t start();
|
||||
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/app/App.h>
|
||||
|
||||
namespace tt::app::launcher {
|
||||
|
||||
LaunchId start();
|
||||
uint32_t start();
|
||||
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/app/App.h>
|
||||
|
||||
namespace tt::app::localesettings {
|
||||
|
||||
LaunchId start();
|
||||
|
||||
}
|
||||
// Intentionally empty: LocaleSettings.cpp has no external callers (verified via repo-wide
|
||||
// grep during its thread-per-app conversion), so it no longer exposes a start() wrapper. This
|
||||
// header is kept as a placeholder in case that changes; nothing currently includes it.
|
||||
@@ -1,10 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/app/App.h>
|
||||
|
||||
namespace tt::app::setup {
|
||||
|
||||
LaunchId start();
|
||||
void start();
|
||||
|
||||
/** @return true if the setup wizard has already run to completion */
|
||||
bool isCompleted();
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/app/App.h>
|
||||
#include <cstdint>
|
||||
|
||||
namespace tt::app::timedatesettings {
|
||||
|
||||
LaunchId start();
|
||||
uint32_t start();
|
||||
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/app/App.h>
|
||||
#include <Tactility/Bundle.h>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace tt::app::timezone {
|
||||
|
||||
LaunchId start(bool saveTimeZone = false);
|
||||
/**
|
||||
* @return the started dialog's instance id; result (0 = Ok, 1 = Cancelled) is delivered to
|
||||
* @a callerAppInstanceId as APP_EVENT_RESULT once the user picks a time zone - call
|
||||
* getLastName()/getLastCode() right after receiving it, on result == 0.
|
||||
*/
|
||||
uint32_t start(uint32_t callerAppInstanceId, bool saveTimeZone = false);
|
||||
|
||||
std::string getResultName(const Bundle& bundle);
|
||||
std::string getResultCode(const Bundle& bundle);
|
||||
/** @return the name/code from the last time zone picked by any TimeZone dialog instance. Only
|
||||
* one dialog is expected to be open at a time. */
|
||||
std::string getLastName();
|
||||
std::string getLastCode();
|
||||
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/service/wifi/WifiApSettings.h>
|
||||
|
||||
namespace tt::app::wificonnect {
|
||||
|
||||
typedef void (*OnConnectSsid)(const service::wifi::settings::WifiApSettings& settings, bool store, void* context);
|
||||
|
||||
typedef struct {
|
||||
OnConnectSsid onConnectSsid;
|
||||
void* onConnectSsidContext;
|
||||
} Bindings;
|
||||
|
||||
} // namespace
|
||||
@@ -1,24 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/Mutex.h>
|
||||
#include <Tactility/service/wifi/WifiApSettings.h>
|
||||
|
||||
namespace tt::app::wificonnect {
|
||||
|
||||
class State final {
|
||||
Mutex lock;
|
||||
service::wifi::settings::WifiApSettings apSettings;
|
||||
bool connectionError = false;
|
||||
bool connecting = false;
|
||||
public:
|
||||
|
||||
void setConnectionError(bool error);
|
||||
bool hasConnectionError() const;
|
||||
|
||||
void setApSettings(const service::wifi::settings::WifiApSettings& newSettings);
|
||||
|
||||
void setConnecting(bool isConnecting);
|
||||
bool isConnecting() const;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
@@ -1,45 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "./Bindings.h"
|
||||
#include "./State.h"
|
||||
|
||||
#include <Tactility/app/AppContext.h>
|
||||
|
||||
#include <lvgl.h>
|
||||
|
||||
namespace tt::app::wificonnect {
|
||||
|
||||
class WifiConnect;
|
||||
|
||||
class View final {
|
||||
|
||||
Bindings* bindings;
|
||||
State* state;
|
||||
|
||||
public:
|
||||
|
||||
lv_obj_t* ssid_textarea = nullptr;
|
||||
lv_obj_t* ssid_error = nullptr;
|
||||
lv_obj_t* password_textarea = nullptr;
|
||||
lv_obj_t* password_error = nullptr;
|
||||
lv_obj_t* connect_button = nullptr;
|
||||
lv_obj_t* remember_switch = nullptr;
|
||||
lv_obj_t* connecting_spinner = nullptr;
|
||||
lv_obj_t* connection_error = nullptr;
|
||||
lv_group_t* group = nullptr;
|
||||
|
||||
View(Bindings* bindings, State* state) :
|
||||
bindings(bindings),
|
||||
state(state)
|
||||
{}
|
||||
|
||||
void init(AppContext& app, lv_obj_t* parent);
|
||||
void update();
|
||||
|
||||
void createBottomButtons(lv_obj_t* parent);
|
||||
void setLoading(bool loading);
|
||||
void resetErrors();
|
||||
};
|
||||
|
||||
|
||||
} // namespace
|
||||
@@ -1,54 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/app/App.h>
|
||||
#include <Tactility/app/wificonnect/Bindings.h>
|
||||
#include <Tactility/app/wificonnect/State.h>
|
||||
#include <Tactility/app/wificonnect/View.h>
|
||||
|
||||
#include <Tactility/Mutex.h>
|
||||
#include <Tactility/service/wifi/Wifi.h>
|
||||
#include <string>
|
||||
|
||||
namespace tt::app::wificonnect {
|
||||
|
||||
class WifiConnect final : public App {
|
||||
|
||||
Mutex mutex;
|
||||
State state;
|
||||
Bindings bindings = {
|
||||
.onConnectSsid = nullptr,
|
||||
.onConnectSsidContext = nullptr
|
||||
};
|
||||
View view = View(&bindings, &state);
|
||||
PubSub<service::wifi::WifiEvent>::SubscriptionHandle wifiSubscription;
|
||||
bool viewEnabled = false;
|
||||
|
||||
void onWifiEvent(service::wifi::WifiEvent event);
|
||||
|
||||
public:
|
||||
|
||||
WifiConnect();
|
||||
~WifiConnect() override;
|
||||
|
||||
void lock();
|
||||
void unlock();
|
||||
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override;
|
||||
void onHide(AppContext& app) override;
|
||||
|
||||
State& getState() { return state; }
|
||||
Bindings& getBindings() { return bindings; }
|
||||
View& getView() { return view; }
|
||||
|
||||
void requestViewUpdate();
|
||||
};
|
||||
|
||||
/**
|
||||
* Start the app with optional pre-filled fields.
|
||||
*/
|
||||
LaunchId start(const std::string& ssid = "", const std::string& password = "");
|
||||
|
||||
bool optSsidParameter(const std::shared_ptr<const Bundle>& bundle, std::string& ssid);
|
||||
|
||||
bool optPasswordParameter(const std::shared_ptr<const Bundle>& bundle, std::string& password);
|
||||
void start(const std::string& ssid = "", const std::string& password = "");
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
#include "./Bindings.h"
|
||||
#include "./State.h"
|
||||
|
||||
#include <Tactility/app/AppContext.h>
|
||||
#include <Tactility/app/AppPaths.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <lvgl.h>
|
||||
|
||||
namespace tt::app::wifimanage {
|
||||
@@ -14,7 +12,7 @@ class View final {
|
||||
|
||||
Bindings* bindings;
|
||||
State* state;
|
||||
std::unique_ptr<AppPaths> paths;
|
||||
uint32_t appInstanceId = 0;
|
||||
lv_obj_t* root = nullptr;
|
||||
lv_obj_t* enable_switch = nullptr;
|
||||
lv_obj_t* enable_on_boot_switch = nullptr;
|
||||
@@ -36,7 +34,7 @@ public:
|
||||
|
||||
View(Bindings* bindings, State* state) : bindings(bindings), state(state) {}
|
||||
|
||||
void init(const AppContext& app, lv_obj_t* parent);
|
||||
void init(uint32_t appInstanceId, lv_obj_t* parent);
|
||||
void update();
|
||||
};
|
||||
|
||||
|
||||
@@ -3,39 +3,11 @@
|
||||
#include "./View.h"
|
||||
#include "./State.h"
|
||||
|
||||
#include <Tactility/app/App.h>
|
||||
|
||||
#include <Tactility/PubSub.h>
|
||||
#include <Tactility/Mutex.h>
|
||||
#include <Tactility/service/wifi/Wifi.h>
|
||||
|
||||
namespace tt::app::wifimanage {
|
||||
|
||||
class WifiManage final : public App {
|
||||
|
||||
PubSub<service::wifi::WifiEvent>::SubscriptionHandle wifiSubscription = nullptr;
|
||||
Mutex mutex;
|
||||
Bindings bindings = { };
|
||||
State state;
|
||||
View view = View(&bindings, &state);
|
||||
bool isViewEnabled = false;
|
||||
|
||||
void onWifiEvent(service::wifi::WifiEvent event);
|
||||
|
||||
public:
|
||||
|
||||
WifiManage();
|
||||
|
||||
void lock();
|
||||
void unlock();
|
||||
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override;
|
||||
void onHide(AppContext& app) override;
|
||||
|
||||
Bindings& getBindings() { return bindings; }
|
||||
State& getState() { return state; }
|
||||
|
||||
void requestViewUpdate();
|
||||
};
|
||||
|
||||
} // namespace
|
||||
// Context (the app's actual runtime state) is defined inside WifiManage.cpp's own anonymous
|
||||
// namespace - View.cpp doesn't need it (Bindings*/State* pointers and a raw appInstanceId are
|
||||
// threaded through explicitly instead, see View.h/View.cpp). This header now only bundles
|
||||
// View.h/State.h for convenience, same as before.
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <Tactility/MessageQueue.h>
|
||||
#include <Tactility/PubSub.h>
|
||||
#include <Tactility/RecursiveMutex.h>
|
||||
#include <Tactility/service/Service.h>
|
||||
#include <Tactility/service/loader/Loader.h>
|
||||
|
||||
#include <Tactility/Semaphore.h>
|
||||
|
||||
#include <tactility/concurrent/dispatcher.h>
|
||||
|
||||
#include <lvgl/devices/keyboard.h>
|
||||
|
||||
namespace tt::service::gui {
|
||||
|
||||
/**
|
||||
* Output a log warning if the current task is the GUI task.
|
||||
* This is meant for code that should either create their own task or use a different task to execute on.
|
||||
* @param[in] context a descriptive name or label that refers to the caller of this function
|
||||
*/
|
||||
void warnIfRunningOnGuiTask(const char* context);
|
||||
|
||||
class GuiService final : public Service {
|
||||
|
||||
// Thread and lock
|
||||
Thread* thread = nullptr;
|
||||
DispatcherHandle_t dispatcher = nullptr;
|
||||
bool exitRequested = false;
|
||||
RecursiveMutex mutex;
|
||||
PubSub<loader::LoaderService::Event>::SubscriptionHandle loader_pubsub_subscription = nullptr;
|
||||
|
||||
// Signaled by hideApp() once App::onHide() has actually finished running on the GUI
|
||||
// task. onLoaderEvent() blocks on this (still on the Loader thread, inside the
|
||||
// synchronous pubsub publish() call) before returning from the ApplicationHiding
|
||||
// branch, so LoaderService::transitionAppToState(Hiding) can't return - and therefore
|
||||
// the immediately-following Destroyed transition (which unloads an ELF app's code via
|
||||
// esp_elf_deinit) can't run - until onHide() has fully completed. Without this, the
|
||||
// ELF's code/data can be unmapped while onHide() (and anything it spawned, like a
|
||||
// camera capture task) is still executing it.
|
||||
Semaphore hideDoneSem { 1, 0 };
|
||||
|
||||
// Layers and Canvas
|
||||
lv_obj_t* appRootWidget = nullptr;
|
||||
lv_obj_t* statusbarWidget = nullptr;
|
||||
|
||||
// App-specific
|
||||
std::shared_ptr<app::AppInstance> appToRender = nullptr;
|
||||
|
||||
LvglSoftwareKeyboard software_keyboard = {};
|
||||
|
||||
bool isStarted = false;
|
||||
|
||||
static int32_t guiMain();
|
||||
|
||||
static void onGuiDispatch(void* context);
|
||||
|
||||
void onLoaderEvent(loader::LoaderService::Event event);
|
||||
|
||||
lv_obj_t* createAppViews(lv_obj_t* parent);
|
||||
|
||||
void redraw();
|
||||
|
||||
void lock() const {
|
||||
check(mutex.lock(pdMS_TO_TICKS(1000)));
|
||||
}
|
||||
|
||||
void unlock() const {
|
||||
mutex.unlock();
|
||||
}
|
||||
|
||||
void showApp(std::shared_ptr<app::AppInstance> app);
|
||||
|
||||
void hideApp();
|
||||
|
||||
public:
|
||||
|
||||
bool onStart(ServiceContext& service) override;
|
||||
|
||||
void onStop(ServiceContext& service) override;
|
||||
|
||||
/**
|
||||
* Show the on-screen keyboard.
|
||||
* @param[in] textarea the textarea to focus the input for
|
||||
*/
|
||||
void softwareKeyboardShow(lv_obj_t* textarea);
|
||||
|
||||
/**
|
||||
* Hide the on-screen keyboard.
|
||||
* Has no effect when the keyboard is not visible.
|
||||
*/
|
||||
void softwareKeyboardHide();
|
||||
|
||||
void keyboardAddTextArea(lv_obj_t* textarea);
|
||||
|
||||
/**
|
||||
* The on-screen keyboard is only shown when both of these conditions are true:
|
||||
* - there is no hardware keyboard
|
||||
* - TT_CONFIG_FORCE_ONSCREEN_KEYBOARD is set to true in tactility_config.h
|
||||
* @return if we should show a on-screen keyboard for text input inside our apps
|
||||
*/
|
||||
bool softwareKeyboardIsEnabled();
|
||||
};
|
||||
|
||||
std::shared_ptr<GuiService> findService();
|
||||
|
||||
} // namespace
|
||||
@@ -1,113 +0,0 @@
|
||||
#include "Tactility/Bundle.h"
|
||||
|
||||
namespace tt {
|
||||
|
||||
bool Bundle::getBool(const std::string& key) const {
|
||||
return this->entries.find(key)->second.value_bool;
|
||||
}
|
||||
|
||||
int32_t Bundle::getInt32(const std::string& key) const {
|
||||
return this->entries.find(key)->second.value_int32;
|
||||
}
|
||||
|
||||
int64_t Bundle::getInt64(const std::string& key) const {
|
||||
return this->entries.find(key)->second.value_int64;
|
||||
}
|
||||
|
||||
std::string Bundle::getString(const std::string& key) const {
|
||||
return this->entries.find(key)->second.value_string;
|
||||
}
|
||||
|
||||
bool Bundle::hasBool(const std::string& key) const {
|
||||
auto entry = this->entries.find(key);
|
||||
return entry != std::end(this->entries) && entry->second.type == Type::Bool;
|
||||
}
|
||||
|
||||
bool Bundle::hasInt32(const std::string& key) const {
|
||||
auto entry = this->entries.find(key);
|
||||
return entry != std::end(this->entries) && entry->second.type == Type::Int32;
|
||||
}
|
||||
|
||||
bool Bundle::hasInt64(const std::string& key) const {
|
||||
auto entry = this->entries.find(key);
|
||||
return entry != std::end(this->entries) && entry->second.type == Type::Int64;
|
||||
}
|
||||
|
||||
bool Bundle::hasString(const std::string& key) const {
|
||||
auto entry = this->entries.find(key);
|
||||
return entry != std::end(this->entries) && entry->second.type == Type::String;
|
||||
}
|
||||
|
||||
bool Bundle::optBool(const std::string& key, bool& out) const {
|
||||
auto entry = this->entries.find(key);
|
||||
if (entry != std::end(this->entries) && entry->second.type == Type::Bool) {
|
||||
out = entry->second.value_bool;
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool Bundle::optInt32(const std::string& key, int32_t& out) const {
|
||||
auto entry = this->entries.find(key);
|
||||
if (entry != std::end(this->entries) && entry->second.type == Type::Int32) {
|
||||
out = entry->second.value_int32;
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool Bundle::optInt64(const std::string& key, int64_t& out) const {
|
||||
auto entry = this->entries.find(key);
|
||||
if (entry != std::end(this->entries) && entry->second.type == Type::Int64) {
|
||||
out = entry->second.value_int64;
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool Bundle::optString(const std::string& key, std::string& out) const {
|
||||
auto entry = this->entries.find(key);
|
||||
if (entry != std::end(this->entries) && entry->second.type == Type::String) {
|
||||
out = entry->second.value_string;
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void Bundle::putBool(const std::string& key, bool value) {
|
||||
this->entries[key] = {
|
||||
.type = Type::Bool,
|
||||
.value_bool = value,
|
||||
.value_string = ""
|
||||
};
|
||||
}
|
||||
|
||||
void Bundle::putInt32(const std::string& key, int32_t value) {
|
||||
this->entries[key] = {
|
||||
.type = Type::Int32,
|
||||
.value_int32 = value,
|
||||
.value_string = ""
|
||||
};
|
||||
}
|
||||
|
||||
void Bundle::putInt64(const std::string& key, int64_t value) {
|
||||
this->entries[key] = {
|
||||
.type = Type::Int64,
|
||||
.value_int64 = value,
|
||||
.value_string = ""
|
||||
};
|
||||
}
|
||||
|
||||
void Bundle::putString(const std::string& key, const std::string& value) {
|
||||
this->entries[key] = {
|
||||
.type = Type::String,
|
||||
.value_bool = false,
|
||||
.value_string = value
|
||||
};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -1,6 +1,7 @@
|
||||
#include <Tactility/Paths.h>
|
||||
#include <Tactility/DeprecatedPaths.h>
|
||||
|
||||
#include "../../Modules/app-module/private/app/private/app_metadata_parsing_internal.h"
|
||||
|
||||
#include <Tactility/app/AppManifestParsing.h>
|
||||
#include <Tactility/MountPoints.h>
|
||||
|
||||
#include <format>
|
||||
@@ -71,12 +72,12 @@ std::string getUserHomePath() {
|
||||
}
|
||||
|
||||
std::string getAppInstallPath(const std::string& appId) {
|
||||
assert(app::isValidId(appId));
|
||||
assert(app_metadata_is_valid_id(appId.c_str()));
|
||||
return std::format("{}/{}", getAppInstallPath(), appId);
|
||||
}
|
||||
|
||||
std::string getAppUserPath(const std::string& appId) {
|
||||
assert(app::isValidId(appId));
|
||||
assert(app_metadata_is_valid_id(appId.c_str()));
|
||||
return std::format("{}/app/{}", getUserHomePath(), appId);
|
||||
}
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
#ifdef ESP_PLATFORM
|
||||
|
||||
#include <Tactility/Preferences.h>
|
||||
#include <Tactility/TactilityCore.h>
|
||||
|
||||
#include <nvs_flash.h>
|
||||
#include <tactility/log.h>
|
||||
|
||||
namespace tt {
|
||||
|
||||
constexpr auto* TAG = "Preferences";
|
||||
|
||||
bool Preferences::optBool(const std::string& key, bool& out) const {
|
||||
nvs_handle_t handle;
|
||||
if (nvs_open(namespace_, NVS_READWRITE, &handle) != ESP_OK) {
|
||||
LOG_E(TAG, "Failed to open namespace %s", namespace_);
|
||||
return false;
|
||||
} else {
|
||||
uint8_t out_number;
|
||||
bool success = nvs_get_u8(handle, key.c_str(), &out_number) == ESP_OK;
|
||||
nvs_close(handle);
|
||||
if (success) {
|
||||
out = (bool)out_number;
|
||||
}
|
||||
return success;
|
||||
}
|
||||
}
|
||||
|
||||
bool Preferences::optInt32(const std::string& key, int32_t& out) const {
|
||||
nvs_handle_t handle;
|
||||
if (nvs_open(namespace_, NVS_READWRITE, &handle) != ESP_OK) {
|
||||
LOG_E(TAG, "Failed to open namespace %s", namespace_);
|
||||
return false;
|
||||
} else {
|
||||
bool success = nvs_get_i32(handle, key.c_str(), &out) == ESP_OK;
|
||||
nvs_close(handle);
|
||||
return success;
|
||||
}
|
||||
}
|
||||
|
||||
bool Preferences::optInt64(const std::string& key, int64_t& out) const {
|
||||
nvs_handle_t handle;
|
||||
if (nvs_open(namespace_, NVS_READWRITE, &handle) != ESP_OK) {
|
||||
LOG_E(TAG, "Failed to open namespace %s", namespace_);
|
||||
return false;
|
||||
} else {
|
||||
bool success = nvs_get_i64(handle, key.c_str(), &out) == ESP_OK;
|
||||
nvs_close(handle);
|
||||
return success;
|
||||
}
|
||||
}
|
||||
|
||||
bool Preferences::optString(const std::string& key, std::string& out) const {
|
||||
nvs_handle_t handle;
|
||||
if (nvs_open(namespace_, NVS_READWRITE, &handle) != ESP_OK) {
|
||||
LOG_E(TAG, "Failed to open namespace %s", namespace_);
|
||||
return false;
|
||||
} else {
|
||||
size_t out_size = 256;
|
||||
char* out_data = static_cast<char*>(malloc(out_size));
|
||||
bool success = nvs_get_str(handle, key.c_str(), out_data, &out_size) == ESP_OK;
|
||||
nvs_close(handle);
|
||||
out = out_data;
|
||||
free(out_data);
|
||||
return success;
|
||||
}
|
||||
}
|
||||
|
||||
bool Preferences::hasBool(const std::string& key) const {
|
||||
bool temp;
|
||||
return optBool(key, temp);
|
||||
}
|
||||
|
||||
bool Preferences::hasInt32(const std::string& key) const {
|
||||
int32_t temp;
|
||||
return optInt32(key, temp);
|
||||
}
|
||||
|
||||
bool Preferences::hasInt64(const std::string& key) const {
|
||||
int64_t temp;
|
||||
return optInt64(key, temp);
|
||||
}
|
||||
|
||||
bool Preferences::hasString(const std::string& key) const {
|
||||
std::string temp;
|
||||
return optString(key, temp);
|
||||
}
|
||||
|
||||
void Preferences::putBool(const std::string& key, bool value) {
|
||||
nvs_handle_t handle;
|
||||
if (nvs_open(namespace_, NVS_READWRITE, &handle) == ESP_OK) {
|
||||
if (nvs_set_u8(handle, key.c_str(), value) != ESP_OK) {
|
||||
LOG_E(TAG, "Failed to set %s:%s", namespace_, key.c_str());
|
||||
} else if (nvs_commit(handle) != ESP_OK) {
|
||||
LOG_E(TAG, "Failed to commit %s:%s", namespace_, key.c_str());
|
||||
}
|
||||
nvs_close(handle);
|
||||
} else {
|
||||
LOG_E(TAG, "Failed to open namespace %s", namespace_);
|
||||
}
|
||||
}
|
||||
|
||||
void Preferences::putInt32(const std::string& key, int32_t value) {
|
||||
nvs_handle_t handle;
|
||||
if (nvs_open(namespace_, NVS_READWRITE, &handle) == ESP_OK) {
|
||||
if (nvs_set_i32(handle, key.c_str(), value) != ESP_OK) {
|
||||
LOG_E(TAG, "Failed to set %s:%s", namespace_, key.c_str());
|
||||
} else if (nvs_commit(handle) != ESP_OK) {
|
||||
LOG_E(TAG, "Failed to commit %s:%s", namespace_, key.c_str());
|
||||
}
|
||||
nvs_close(handle);
|
||||
} else {
|
||||
LOG_E(TAG, "Failed to open namespace %s", namespace_);
|
||||
}
|
||||
}
|
||||
|
||||
void Preferences::putInt64(const std::string& key, int64_t value) {
|
||||
nvs_handle_t handle;
|
||||
if (nvs_open(namespace_, NVS_READWRITE, &handle) == ESP_OK) {
|
||||
if (nvs_set_i64(handle, key.c_str(), value) != ESP_OK) {
|
||||
LOG_E(TAG, "Failed to set %s:%s", namespace_, key.c_str());
|
||||
} else if (nvs_commit(handle) != ESP_OK) {
|
||||
LOG_E(TAG, "Failed to commit %s:%s", namespace_, key.c_str());
|
||||
}
|
||||
nvs_close(handle);
|
||||
} else {
|
||||
LOG_E(TAG, "Failed to open namespace %s", namespace_);
|
||||
}
|
||||
}
|
||||
|
||||
void Preferences::putString(const std::string& key, const std::string& text) {
|
||||
nvs_handle_t handle;
|
||||
if (nvs_open(namespace_, NVS_READWRITE, &handle) == ESP_OK) {
|
||||
if (nvs_set_str(handle, key.c_str(), text.c_str()) != ESP_OK) {
|
||||
LOG_E(TAG, "Failed to set %s:%s", namespace_, key.c_str());
|
||||
} else if (nvs_commit(handle) != ESP_OK) {
|
||||
LOG_E(TAG, "Failed to commit %s:%s", namespace_, key.c_str());
|
||||
}
|
||||
nvs_close(handle);
|
||||
} else {
|
||||
LOG_E(TAG, "Failed to open namespace %s", namespace_);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
#endif
|
||||
@@ -1,84 +0,0 @@
|
||||
#ifndef ESP_PLATFOM
|
||||
|
||||
#include <Tactility/Preferences.h>
|
||||
#include <Tactility/Bundle.h>
|
||||
|
||||
namespace tt {
|
||||
|
||||
static Bundle preferences;
|
||||
|
||||
/**
|
||||
* Creates a string that is effectively "namespace:key" so we can create a single map (bundle)
|
||||
* to store all the key/value pairs.
|
||||
*
|
||||
* @param[in] namespace
|
||||
* @param[in] key
|
||||
* @param[out] out
|
||||
*/
|
||||
std::string get_bundle_key(const std::string& namespace_, const std::string& key) {
|
||||
return namespace_ + ':' + key;
|
||||
}
|
||||
|
||||
bool Preferences::hasBool(const std::string& key) const {
|
||||
std::string bundle_key = get_bundle_key(namespace_, key);
|
||||
return preferences.hasBool(bundle_key);
|
||||
}
|
||||
|
||||
bool Preferences::hasInt32(const std::string& key) const {
|
||||
std::string bundle_key = get_bundle_key(namespace_, key);
|
||||
return preferences.hasInt32(bundle_key);
|
||||
}
|
||||
|
||||
bool Preferences::hasInt64(const std::string& key) const {
|
||||
std::string bundle_key = get_bundle_key(namespace_, key);
|
||||
return preferences.hasInt64(bundle_key);
|
||||
}
|
||||
|
||||
bool Preferences::hasString(const std::string& key) const {
|
||||
std::string bundle_key = get_bundle_key(namespace_, key);
|
||||
return preferences.hasString(bundle_key);
|
||||
}
|
||||
|
||||
bool Preferences::optBool(const std::string& key, bool& out) const {
|
||||
std::string bundle_key = get_bundle_key(namespace_, key);
|
||||
return preferences.optBool(bundle_key, out);
|
||||
}
|
||||
|
||||
bool Preferences::optInt32(const std::string& key, int32_t& out) const {
|
||||
std::string bundle_key = get_bundle_key(namespace_, key);
|
||||
return preferences.optInt32(bundle_key, out);
|
||||
}
|
||||
|
||||
bool Preferences::optInt64(const std::string& key, int64_t& out) const {
|
||||
std::string bundle_key = get_bundle_key(namespace_, key);
|
||||
return preferences.optInt64(bundle_key, out);
|
||||
}
|
||||
|
||||
bool Preferences::optString(const std::string& key, std::string& out) const {
|
||||
std::string bundle_key = get_bundle_key(namespace_, key);
|
||||
return preferences.optString(bundle_key, out);
|
||||
}
|
||||
|
||||
void Preferences::putBool(const std::string& key, bool value) {
|
||||
std::string bundle_key = get_bundle_key(namespace_, key);
|
||||
return preferences.putBool(bundle_key, value);
|
||||
}
|
||||
|
||||
void Preferences::putInt32(const std::string& key, int32_t value) {
|
||||
std::string bundle_key = get_bundle_key(namespace_, key);
|
||||
return preferences.putInt32(bundle_key, value);
|
||||
}
|
||||
|
||||
void Preferences::putInt64(const std::string& key, int64_t value) {
|
||||
std::string bundle_key = get_bundle_key(namespace_, key);
|
||||
return preferences.putInt64(bundle_key, value);
|
||||
}
|
||||
|
||||
void Preferences::putString(const std::string& key, const std::string& value) {
|
||||
std::string bundle_key = get_bundle_key(namespace_, key);
|
||||
return preferences.putString(bundle_key, value);
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
} // namespace
|
||||
+183
-135
@@ -1,39 +1,56 @@
|
||||
#ifdef ESP_PLATFORM
|
||||
#include <sdkconfig.h>
|
||||
#include <Tactility/InitEsp.h>
|
||||
#include <app_esp32/module.h>
|
||||
#endif
|
||||
|
||||
#include <format>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <app/event.h>
|
||||
#include <app/install.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
#include <app/module.h>
|
||||
|
||||
#include <Tactility/Tactility.h>
|
||||
|
||||
#include <Tactility/CpuAffinity.h>
|
||||
#include <Tactility/DeprecatedPaths.h>
|
||||
#include <Tactility/LogMessages.h>
|
||||
#include <Tactility/MountPoints.h>
|
||||
#include <Tactility/TactilityConfig.h>
|
||||
#include <Tactility/bluetooth/Bluetooth.h>
|
||||
#include <Tactility/CpuAffinity.h>
|
||||
#include <Tactility/MountPoints.h>
|
||||
#include <Tactility/app/AppManifestParsing.h>
|
||||
#include <Tactility/app/AppRegistration.h>
|
||||
#include <Tactility/file/File.h>
|
||||
#include <Tactility/LogMessages.h>
|
||||
#include <Tactility/lvgl/TrackballInit.h>
|
||||
#include <Tactility/hal/SdCard.h>
|
||||
#include <Tactility/lvgl/Statusbar.h>
|
||||
#include <Tactility/lvgl/TrackballInit.h>
|
||||
#include <Tactility/lvgl/UsbHidInput.h>
|
||||
#include <Tactility/network/NtpPrivate.h>
|
||||
#include <Tactility/Paths.h>
|
||||
#include <Tactility/service/ServiceManifest.h>
|
||||
#include <Tactility/service/ServiceRegistration.h>
|
||||
#include <Tactility/service/audio/Audio.h>
|
||||
#include <Tactility/settings/TimePrivate.h>
|
||||
#include <Tactility/settings/TouchCalibrationSettings.h>
|
||||
|
||||
#include <crypt/module.h>
|
||||
|
||||
#include <gps/module.h>
|
||||
#include <gps_generic/module.h>
|
||||
#include <gps_meshtastic/module.h>
|
||||
|
||||
#include <crypt/module.h>
|
||||
#include <lvgl/devices/keyboard.h>
|
||||
#include <lvgl/devices/pointer.h>
|
||||
#include <lvgl/lvgl.h>
|
||||
#include <lvgl/module.h>
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
|
||||
#include <lvgl_window_manager/module.h>
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <tactility/concurrent/thread.h>
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/audio_stream.h>
|
||||
@@ -88,8 +105,6 @@ namespace service {
|
||||
namespace espnow { extern const ServiceManifest manifest; }
|
||||
#endif
|
||||
// Secondary (UI)
|
||||
namespace gui { extern const ServiceManifest manifest; }
|
||||
namespace loader { extern const ServiceManifest manifest; }
|
||||
namespace memorychecker { extern const ServiceManifest manifest; }
|
||||
namespace statusbar { extern const ServiceManifest manifest; }
|
||||
#ifdef ESP_PLATFORM
|
||||
@@ -110,64 +125,66 @@ namespace service {
|
||||
|
||||
// region Default apps
|
||||
|
||||
// All apps below are converted to the new app-module + window-manager model, so their manifest
|
||||
// is the new, global ::AppManifest, not this namespace's old tt::app::AppManifest.
|
||||
namespace app {
|
||||
namespace addgps { extern const AppManifest manifest; }
|
||||
namespace alertdialog { extern const AppManifest manifest; }
|
||||
namespace apphub { extern const AppManifest manifest; }
|
||||
namespace apphubdetails { extern const AppManifest manifest; }
|
||||
namespace appdetails { extern const AppManifest manifest; }
|
||||
namespace applist { extern const AppManifest manifest; }
|
||||
namespace appsettings { extern const AppManifest manifest; }
|
||||
namespace audiosettings { extern const AppManifest manifest; }
|
||||
namespace boot { extern const AppManifest manifest; }
|
||||
namespace development { extern const AppManifest manifest; }
|
||||
namespace display { extern const AppManifest manifest; }
|
||||
namespace kerneldisplay { extern const AppManifest manifest; }
|
||||
namespace files { extern const AppManifest manifest; }
|
||||
namespace fileselection { extern const AppManifest manifest; }
|
||||
namespace gpssettings { extern const AppManifest manifest; }
|
||||
namespace grovesettings { extern const AppManifest manifest; }
|
||||
namespace i2cscanner { extern const AppManifest manifest; }
|
||||
namespace imageviewer { extern const AppManifest manifest; }
|
||||
namespace inputdialog { extern const AppManifest manifest; }
|
||||
namespace launcher { extern const AppManifest manifest; }
|
||||
namespace localesettings { extern const AppManifest manifest; }
|
||||
namespace notes { extern const AppManifest manifest; }
|
||||
namespace power { extern const AppManifest manifest; }
|
||||
namespace poweroff { extern const AppManifest manifest; }
|
||||
namespace selectiondialog { extern const AppManifest manifest; }
|
||||
namespace settings { extern const AppManifest manifest; }
|
||||
namespace setup { extern const AppManifest manifest; }
|
||||
namespace systeminfo { extern const AppManifest manifest; }
|
||||
namespace timedatesettings { extern const AppManifest manifest; }
|
||||
namespace addgps { extern const ::AppManifest manifest; }
|
||||
namespace alertdialog { extern const ::AppManifest manifest; }
|
||||
namespace apphub { extern const ::AppManifest manifest; }
|
||||
namespace apphubdetails { extern const ::AppManifest manifest; }
|
||||
namespace appdetails { extern const ::AppManifest manifest; }
|
||||
namespace applist { extern const ::AppManifest manifest; }
|
||||
namespace appsettings { extern const ::AppManifest manifest; }
|
||||
namespace audiosettings { extern const ::AppManifest manifest; }
|
||||
namespace boot { extern const ::AppManifest manifest; }
|
||||
namespace development { extern const ::AppManifest manifest; }
|
||||
namespace display { extern const ::AppManifest manifest; }
|
||||
namespace kerneldisplay { extern const ::AppManifest manifest; }
|
||||
namespace files { extern const ::AppManifest manifest; }
|
||||
namespace fileselection { extern const ::AppManifest manifest; }
|
||||
namespace gpssettings { extern const ::AppManifest manifest; }
|
||||
namespace grovesettings { extern const ::AppManifest manifest; }
|
||||
namespace i2cscanner { extern const ::AppManifest manifest; }
|
||||
namespace imageviewer { extern const ::AppManifest manifest; }
|
||||
namespace inputdialog { extern const ::AppManifest manifest; }
|
||||
namespace launcher { extern const ::AppManifest manifest; }
|
||||
namespace localesettings { extern const ::AppManifest manifest; }
|
||||
namespace notes { extern const ::AppManifest manifest; }
|
||||
namespace power { extern const ::AppManifest manifest; }
|
||||
namespace poweroff { extern const ::AppManifest manifest; }
|
||||
namespace selectiondialog { extern const ::AppManifest manifest; }
|
||||
namespace settings { extern const ::AppManifest manifest; }
|
||||
namespace setup { extern const ::AppManifest manifest; }
|
||||
namespace systeminfo { extern const ::AppManifest manifest; }
|
||||
namespace timedatesettings { extern const ::AppManifest manifest; }
|
||||
#ifdef CONFIG_TT_TOUCH_CALIBRATION_SUPPORTED
|
||||
namespace touchcalibration { extern const AppManifest manifest; }
|
||||
namespace touchcalibration { extern const ::AppManifest manifest; }
|
||||
#endif
|
||||
namespace timezone { extern const AppManifest manifest; }
|
||||
namespace usbsettings { extern const AppManifest manifest; }
|
||||
namespace btmanage { extern const AppManifest manifest; }
|
||||
namespace btpeersettings { extern const AppManifest manifest; }
|
||||
namespace wifiapsettings { extern const AppManifest manifest; }
|
||||
namespace wificonnect { extern const AppManifest manifest; }
|
||||
namespace wifimanage { extern const AppManifest manifest; }
|
||||
namespace timezone { extern const ::AppManifest manifest; }
|
||||
namespace usbsettings { extern const ::AppManifest manifest; }
|
||||
namespace btmanage { extern const ::AppManifest manifest; }
|
||||
namespace btpeersettings { extern const ::AppManifest manifest; }
|
||||
namespace wifiapsettings { extern const ::AppManifest manifest; }
|
||||
namespace wificonnect { extern const ::AppManifest manifest; }
|
||||
namespace wifimanage { extern const ::AppManifest manifest; }
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
namespace apwebserver { extern const AppManifest manifest; }
|
||||
namespace crashdiagnostics { extern const AppManifest manifest; }
|
||||
namespace webserversettings { extern const AppManifest manifest; }
|
||||
namespace apwebserver { extern const ::AppManifest manifest; }
|
||||
namespace crashdiagnostics { extern const ::AppManifest manifest; }
|
||||
namespace webserversettings { extern const ::AppManifest manifest; }
|
||||
#if CONFIG_TT_TDECK_WORKAROUND == 1
|
||||
namespace keyboardsettings { extern const AppManifest manifest; } // T-Deck only for now
|
||||
namespace keyboardsettings { extern const ::AppManifest manifest; } // T-Deck only for now
|
||||
#endif
|
||||
#endif
|
||||
|
||||
namespace trackballsettings { extern const AppManifest manifest; } // T-Deck only for now
|
||||
namespace trackballsettings { extern const ::AppManifest manifest; } // T-Deck only for now
|
||||
|
||||
#if TT_FEATURE_SCREENSHOT_ENABLED
|
||||
namespace screenshot { extern const AppManifest manifest; }
|
||||
namespace screenshot { extern const ::AppManifest manifest; }
|
||||
#endif
|
||||
|
||||
#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED)
|
||||
namespace chat { extern const AppManifest manifest; }
|
||||
namespace chat { extern const ::AppManifest manifest; }
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -177,118 +194,90 @@ namespace app {
|
||||
static void registerInternalApps() {
|
||||
LOG_I(TAG, "Registering internal apps");
|
||||
|
||||
addAppManifest(app::alertdialog::manifest);
|
||||
addAppManifest(app::appdetails::manifest);
|
||||
addAppManifest(app::apphub::manifest);
|
||||
addAppManifest(app::apphubdetails::manifest);
|
||||
addAppManifest(app::applist::manifest);
|
||||
addAppManifest(app::appsettings::manifest);
|
||||
app_manager_add(&app::alertdialog::manifest);
|
||||
app_manager_add(&app::appdetails::manifest);
|
||||
app_manager_add(&app::apphub::manifest);
|
||||
app_manager_add(&app::apphubdetails::manifest);
|
||||
app_manager_add(&app::applist::manifest);
|
||||
app_manager_add(&app::appsettings::manifest);
|
||||
if (service::audio::isAvailable()) {
|
||||
addAppManifest(app::audiosettings::manifest);
|
||||
app_manager_add(&app::audiosettings::manifest);
|
||||
}
|
||||
if (device_exists_of_type(&DISPLAY_TYPE)) {
|
||||
addAppManifest(app::kerneldisplay::manifest);
|
||||
app_manager_add(&app::kerneldisplay::manifest);
|
||||
}
|
||||
addAppManifest(app::files::manifest);
|
||||
addAppManifest(app::fileselection::manifest);
|
||||
addAppManifest(app::i2cscanner::manifest);
|
||||
addAppManifest(app::imageviewer::manifest);
|
||||
addAppManifest(app::inputdialog::manifest);
|
||||
addAppManifest(app::launcher::manifest);
|
||||
addAppManifest(app::localesettings::manifest);
|
||||
addAppManifest(app::notes::manifest);
|
||||
app_manager_add(&app::files::manifest);
|
||||
app_manager_add(&app::fileselection::manifest);
|
||||
app_manager_add(&app::i2cscanner::manifest);
|
||||
app_manager_add(&app::imageviewer::manifest);
|
||||
app_manager_add(&app::inputdialog::manifest);
|
||||
app_manager_add(&app::launcher::manifest);
|
||||
app_manager_add(&app::localesettings::manifest);
|
||||
app_manager_add(&app::notes::manifest);
|
||||
if (device_exists_of_type(&POWER_SUPPLY_TYPE)) {
|
||||
addAppManifest(app::poweroff::manifest);
|
||||
app_manager_add(&app::poweroff::manifest);
|
||||
}
|
||||
addAppManifest(app::settings::manifest);
|
||||
addAppManifest(app::selectiondialog::manifest);
|
||||
addAppManifest(app::setup::manifest);
|
||||
addAppManifest(app::systeminfo::manifest);
|
||||
addAppManifest(app::timedatesettings::manifest);
|
||||
app_manager_add(&app::settings::manifest);
|
||||
app_manager_add(&app::selectiondialog::manifest);
|
||||
app_manager_add(&app::setup::manifest);
|
||||
app_manager_add(&app::systeminfo::manifest);
|
||||
app_manager_add(&app::timedatesettings::manifest);
|
||||
#ifdef CONFIG_TT_TOUCH_CALIBRATION_SUPPORTED
|
||||
addAppManifest(app::touchcalibration::manifest);
|
||||
app_manager_add(&app::touchcalibration::manifest);
|
||||
#endif
|
||||
addAppManifest(app::timezone::manifest);
|
||||
addAppManifest(app::wifiapsettings::manifest);
|
||||
addAppManifest(app::wificonnect::manifest);
|
||||
addAppManifest(app::wifimanage::manifest);
|
||||
app_manager_add(&app::timezone::manifest);
|
||||
app_manager_add(&app::wifiapsettings::manifest);
|
||||
app_manager_add(&app::wificonnect::manifest);
|
||||
app_manager_add(&app::wifimanage::manifest);
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
addAppManifest(app::apwebserver::manifest);
|
||||
addAppManifest(app::webserversettings::manifest);
|
||||
addAppManifest(app::crashdiagnostics::manifest);
|
||||
addAppManifest(app::development::manifest);
|
||||
app_manager_add(&app::apwebserver::manifest);
|
||||
app_manager_add(&app::webserversettings::manifest);
|
||||
app_manager_add(&app::crashdiagnostics::manifest);
|
||||
app_manager_add(&app::development::manifest);
|
||||
#if defined(CONFIG_TT_TDECK_WORKAROUND)
|
||||
addAppManifest(app::keyboardsettings::manifest);
|
||||
app_manager_add(&app::keyboardsettings::manifest);
|
||||
#endif
|
||||
#endif
|
||||
|
||||
if (device_exists_of_type(&TRACKBALL_TYPE)) {
|
||||
addAppManifest(app::trackballsettings::manifest);
|
||||
app_manager_add(&app::trackballsettings::manifest);
|
||||
}
|
||||
|
||||
#if defined(CONFIG_TINYUSB_MSC_ENABLED) && CONFIG_TINYUSB_MSC_ENABLED
|
||||
addAppManifest(app::usbsettings::manifest);
|
||||
app_manager_add(&app::usbsettings::manifest);
|
||||
#endif
|
||||
|
||||
#if TT_FEATURE_SCREENSHOT_ENABLED
|
||||
addAppManifest(app::screenshot::manifest);
|
||||
app_manager_add(&app::screenshot::manifest);
|
||||
#endif
|
||||
|
||||
#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED)
|
||||
addAppManifest(app::chat::manifest);
|
||||
app_manager_add(&app::chat::manifest);
|
||||
#endif
|
||||
|
||||
if (device_exists_of_type(&GROVE_TYPE)) {
|
||||
addAppManifest(app::grovesettings::manifest);
|
||||
app_manager_add(&app::grovesettings::manifest);
|
||||
}
|
||||
|
||||
if (device_exists_of_type(&UART_CONTROLLER_TYPE) || device_exists_of_type(&GROVE_TYPE)) {
|
||||
addAppManifest(app::addgps::manifest);
|
||||
addAppManifest(app::gpssettings::manifest);
|
||||
app_manager_add(&app::addgps::manifest);
|
||||
app_manager_add(&app::gpssettings::manifest);
|
||||
}
|
||||
|
||||
if (device_exists_of_type(&POWER_SUPPLY_TYPE)) {
|
||||
addAppManifest(app::power::manifest);
|
||||
app_manager_add(&app::power::manifest);
|
||||
}
|
||||
|
||||
#if defined(CONFIG_BT_ENABLED) && CONFIG_BT_ENABLED
|
||||
addAppManifest(app::btmanage::manifest);
|
||||
addAppManifest(app::btpeersettings::manifest);
|
||||
app_manager_add(&app::btmanage::manifest);
|
||||
app_manager_add(&app::btpeersettings::manifest);
|
||||
#endif
|
||||
}
|
||||
|
||||
static void registerInstalledApp(std::string path) {
|
||||
LOG_I(TAG, "Registering app at %s", path.c_str());
|
||||
std::string manifest_path = path + "/manifest.properties";
|
||||
if (!file::isFile(manifest_path)) {
|
||||
LOG_E(TAG, "Manifest not found at %s", manifest_path.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
app::AppManifest manifest;
|
||||
if (!app::parseManifest(manifest_path, manifest)) {
|
||||
LOG_E(TAG, "Failed to parse manifest at %s", manifest_path.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
manifest.appCategory = app::Category::User;
|
||||
manifest.appLocation = app::Location::external(path);
|
||||
|
||||
app::addAppManifest(manifest);
|
||||
}
|
||||
|
||||
static void registerInstalledApps(const std::string& path) {
|
||||
LOG_I(TAG, "Registering apps from %s", path.c_str());
|
||||
|
||||
file::listDirectory(path, [&path](const auto& entry) {
|
||||
auto absolute_path = std::format("{}/{}", path, entry.d_name);
|
||||
if (file::isDirectory(absolute_path)) {
|
||||
registerInstalledApp(absolute_path);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Registers every mounted filesystem's app install directory with app-module (see
|
||||
// app_manager_install_path_add()/app_manager_install_path_scan() in app/install.h), then scans
|
||||
// them once to register whatever's already installed there.
|
||||
static void registerInstalledAppsFromFileSystems() {
|
||||
file_system_for_each(nullptr, [](auto* fs, void* context) {
|
||||
if (!file_system_is_mounted(fs)) return true;
|
||||
@@ -296,11 +285,12 @@ static void registerInstalledAppsFromFileSystems() {
|
||||
if (file_system_get_path(fs, path, sizeof(path)) != ERROR_NONE) return true;
|
||||
const auto app_path = std::format("{}/tactility/app", path);
|
||||
if (!app_path.starts_with(file::MOUNT_POINT_SYSTEM) && file::isDirectory(app_path)) {
|
||||
LOG_I(TAG, "Registering apps from %s", app_path.c_str());
|
||||
registerInstalledApps(app_path);
|
||||
LOG_I(TAG, "Registering install path %s", app_path.c_str());
|
||||
app_manager_install_path_add(app_path.c_str());
|
||||
}
|
||||
return true;
|
||||
});
|
||||
app_manager_install_path_scan();
|
||||
}
|
||||
|
||||
static void registerAndStartServices() {
|
||||
@@ -319,7 +309,6 @@ static void registerAndStartServices() {
|
||||
#ifdef ESP_PLATFORM
|
||||
addService(service::webserver::manifest);
|
||||
#endif
|
||||
addService(service::loader::manifest);
|
||||
#if defined(ESP_PLATFORM)
|
||||
if (device_exists_of_type(&RTC_TYPE)) {
|
||||
addService(service::rtctime::manifest);
|
||||
@@ -360,7 +349,49 @@ void registerApps() {
|
||||
}
|
||||
|
||||
static void stopAppFromToolbar(lv_event_t*) {
|
||||
app::stop();
|
||||
// Default nav action for any toolbar that doesn't override it itself. Prefer the topmost
|
||||
// new-model app if one is showing; fall back to the old system otherwise (this is what
|
||||
// every not-yet-converted app's toolbar still relies on).
|
||||
AppInstanceId topmost = 0;
|
||||
check(app_manager_get_topmost_instance_id(&topmost) == ERROR_NONE);
|
||||
// Async, non-blocking - must NOT call app_manager_stop() directly here: that
|
||||
// bound-waits (thread_join) for the app's own thread to finish, which needs the LVGL
|
||||
// lock to clean up - but this callback runs ON the LVGL task, which would deadlock
|
||||
// against itself.
|
||||
AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(topmost, &event);
|
||||
}
|
||||
|
||||
// The on-screen keyboard widget itself, constructed during windowManagerScreenInit
|
||||
static LvglSoftwareKeyboard softwareKeyboard { .object = nullptr };
|
||||
|
||||
static lv_obj_t* windowManagerScreenInit(lv_obj_t* root) {
|
||||
lv_obj_t* vertical_container = lv_obj_create(root);
|
||||
lv_obj_set_size(vertical_container, LV_PCT(100), LV_PCT(100));
|
||||
lv_obj_set_flex_flow(vertical_container, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_all(vertical_container, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_gap(vertical_container, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_bg_color(vertical_container, lv_color_black(), LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(vertical_container, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_radius(vertical_container, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_remove_flag(vertical_container, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
lvgl::statusbar_create(vertical_container);
|
||||
|
||||
auto* app_container = lv_obj_create(vertical_container);
|
||||
lv_obj_set_style_pad_all(app_container, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(app_container, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_width(app_container, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(app_container, 1);
|
||||
lv_obj_set_flex_flow(app_container, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_remove_flag(app_container, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
// Parented to root (not app_container/vertical_container) so it overlays on top of
|
||||
// everything, including the statusbar, regardless of which app is showing. Hidden until a
|
||||
// focused textarea shows it (see lvgl_keyboard_add_textarea()/textarea_show_keyboard()).
|
||||
lvgl_software_keyboard_construct(&softwareKeyboard, root);
|
||||
|
||||
return app_container;
|
||||
}
|
||||
|
||||
#ifdef CONFIG_TT_TOUCH_CALIBRATION_SUPPORTED
|
||||
@@ -391,10 +422,12 @@ static void applySavedTouchCalibration() {
|
||||
#endif // CONFIG_TT_TOUCH_CALIBRATION_SUPPORTED
|
||||
|
||||
static void onLvglStarted() {
|
||||
window_manager_configure(windowManagerScreenInit);
|
||||
check(module_ensure_started(&lvgl_window_manager_module) == ERROR_NONE);
|
||||
|
||||
ToolbarConfig toolbar_config = { .nav_action_callback = stopAppFromToolbar };
|
||||
lvgl_toolbar_configure(&toolbar_config);
|
||||
|
||||
addService(service::gui::manifest);
|
||||
addService(service::statusbar::manifest);
|
||||
addService(service::memorychecker::manifest);
|
||||
#if defined(ESP_PLATFORM)
|
||||
@@ -407,6 +440,7 @@ static void onLvglStarted() {
|
||||
addService(service::screenshot::manifest);
|
||||
#endif
|
||||
|
||||
lvgl::startUsbHidInput();
|
||||
lvgl::initTrackball();
|
||||
|
||||
#ifdef CONFIG_TT_TOUCH_CALIBRATION_SUPPORTED
|
||||
@@ -417,6 +451,14 @@ static void onLvglStarted() {
|
||||
}
|
||||
|
||||
static void onLvglStopped() {
|
||||
if (softwareKeyboard.object != nullptr) {
|
||||
lvgl_software_keyboard_destruct(&softwareKeyboard);
|
||||
}
|
||||
|
||||
module_stop(&lvgl_window_manager_module);
|
||||
|
||||
lvgl::stopUsbHidInput();
|
||||
|
||||
#if TT_FEATURE_SCREENSHOT_ENABLED
|
||||
check(service::removeService(service::screenshot::manifest.id));
|
||||
#endif
|
||||
@@ -428,7 +470,6 @@ static void onLvglStopped() {
|
||||
#endif
|
||||
check(service::removeService(service::memorychecker::manifest.id));
|
||||
check(service::removeService(service::statusbar::manifest.id));
|
||||
check(service::removeService(service::gui::manifest.id));
|
||||
|
||||
memory_print_stats();
|
||||
}
|
||||
@@ -446,6 +487,11 @@ void run(Module* const dtsModules[], const DtsDevice dtsDevices[]) {
|
||||
check(module_ensure_started(&gps_module) == ERROR_NONE);
|
||||
check(module_ensure_started(&gps_generic_module) == ERROR_NONE);
|
||||
check(module_ensure_started(&gps_meshtastic_module) == ERROR_NONE);
|
||||
// Registers the APP_LOCATION_MEMORY app loader (boot/launcher need it below).
|
||||
check(module_ensure_started(&app_module) == ERROR_NONE);
|
||||
#ifdef ESP_PLATFORM
|
||||
check(module_ensure_started(&app_esp32_module) == ERROR_NONE);
|
||||
#endif
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
initEsp();
|
||||
@@ -481,9 +527,11 @@ void run(Module* const dtsModules[], const DtsDevice dtsDevices[]) {
|
||||
LOG_I(TAG, "Core systems ready");
|
||||
|
||||
LOG_I(TAG, "Starting boot app");
|
||||
// The boot app takes care of registering system apps, user services and user apps
|
||||
addAppManifest(app::boot::manifest);
|
||||
app::start(app::boot::manifest.appId);
|
||||
// The boot app takes care of registering system apps, user services and user apps.
|
||||
// It's a new-model (app-module + window-manager) app now, replacing the old app::start().
|
||||
app_manager_add(&app::boot::manifest);
|
||||
uint32_t boot_instance_id = 0;
|
||||
app_manager_start(app::boot::manifest.id, &boot_instance_id);
|
||||
|
||||
LOG_I(TAG, "Main dispatcher ready");
|
||||
while (true) {
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
#include <Tactility/app/App.h>
|
||||
#include <Tactility/service/loader/Loader.h>
|
||||
|
||||
namespace tt::app {
|
||||
|
||||
constexpr auto* TAG = "App";
|
||||
|
||||
LaunchId start(const std::string& id, std::shared_ptr<const Bundle> parameters) {
|
||||
const auto service = service::loader::findLoaderService();
|
||||
assert(service != nullptr);
|
||||
return service->start(id, std::move(parameters));
|
||||
}
|
||||
|
||||
void stop() {
|
||||
const auto service = service::loader::findLoaderService();
|
||||
assert(service != nullptr);
|
||||
service->stopTop();
|
||||
}
|
||||
|
||||
void stop(const std::string& id) {
|
||||
const auto service = service::loader::findLoaderService();
|
||||
assert(service != nullptr);
|
||||
service->stopTop(id);
|
||||
}
|
||||
|
||||
void stopAll(const std::string& id) {
|
||||
const auto service = service::loader::findLoaderService();
|
||||
assert(service != nullptr);
|
||||
service->stopAll(id);
|
||||
}
|
||||
|
||||
bool isRunning(const std::string& id) {
|
||||
const auto service = service::loader::findLoaderService();
|
||||
assert(service != nullptr);
|
||||
return service->isRunning(id);
|
||||
}
|
||||
|
||||
std::shared_ptr<AppContext> getCurrentAppContext() {
|
||||
const auto service = service::loader::findLoaderService();
|
||||
assert(service != nullptr);
|
||||
return service->getCurrentAppContext();
|
||||
}
|
||||
|
||||
std::shared_ptr<App> getCurrentApp() {
|
||||
const auto app_context = getCurrentAppContext();
|
||||
return (app_context != nullptr) ? app_context->getApp() : nullptr;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
#include <Tactility/app/App.h>
|
||||
#include <Tactility/app/AppManifestParsing.h>
|
||||
#include <Tactility/app/AppManifest.h>
|
||||
#include <Tactility/app/AppRegistration.h>
|
||||
#include <Tactility/file/File.h>
|
||||
#include <Tactility/Paths.h>
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <format>
|
||||
#include <map>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <minitar.h>
|
||||
#include <tactility/filesystem/file_mutex.h>
|
||||
#include <tactility/log.h>
|
||||
|
||||
namespace tt::app {
|
||||
|
||||
constexpr auto* TAG = "App";
|
||||
|
||||
static bool untarFile(minitar* mp, const minitar_entry* entry, const std::string& destinationPath) {
|
||||
const auto absolute_path = destinationPath + "/" + entry->metadata.path;
|
||||
if (!file::findOrCreateDirectory(destinationPath, 0777)) {
|
||||
LOG_E(TAG, "Can't find or create directory %s", destinationPath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// minitar_read_contents(&mp, &entry, file_buffer, entry.metadata.size);
|
||||
if (!minitar_read_contents_to_file(mp, entry, absolute_path.c_str())) {
|
||||
LOG_E(TAG, "Failed to write data to %s", absolute_path.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Note: fchmod() doesn't exist on ESP-IDF and chmod() does nothing on that platform
|
||||
if (chmod(absolute_path.c_str(), entry->metadata.mode) < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool untarDirectory(const minitar_entry* entry, const std::string& destinationPath) {
|
||||
auto absolute_path = destinationPath + "/" + entry->metadata.path;
|
||||
if (!file::findOrCreateDirectory(absolute_path, 0777)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool untar(const std::string& tarPath, const std::string& destinationPath) {
|
||||
minitar mp;
|
||||
if (minitar_open(tarPath.c_str(), &mp) != 0) {
|
||||
perror(tarPath.c_str());
|
||||
return 1;
|
||||
}
|
||||
bool success = true;
|
||||
minitar_entry entry;
|
||||
|
||||
do {
|
||||
if (minitar_read_entry(&mp, &entry) == 0) {
|
||||
LOG_I(TAG, "Extracting %s", entry.metadata.path);
|
||||
if (entry.metadata.type == MTAR_DIRECTORY) {
|
||||
if (!strcmp(entry.metadata.name, ".") || !strcmp(entry.metadata.name, "..") || !strcmp(entry.metadata.name, "/")) continue;
|
||||
if (!untarDirectory(&entry, destinationPath)) {
|
||||
LOG_E(TAG, "Failed to create directory %s/%s: %s", destinationPath.c_str(), entry.metadata.name, strerror(errno));
|
||||
success = false;
|
||||
break;
|
||||
}
|
||||
} else if (entry.metadata.type == MTAR_REGULAR) {
|
||||
if (!untarFile(&mp, &entry, destinationPath)) {
|
||||
LOG_E(TAG, "Failed to extract file %s: %s", entry.metadata.path, strerror(errno));
|
||||
success = false;
|
||||
break;
|
||||
}
|
||||
} else if (entry.metadata.type == MTAR_SYMLINK) {
|
||||
LOG_E(TAG, "SYMLINK not supported");
|
||||
} else if (entry.metadata.type == MTAR_HARDLINK) {
|
||||
LOG_E(TAG, "HARDLINK not supported");
|
||||
} else if (entry.metadata.type == MTAR_FIFO) {
|
||||
LOG_E(TAG, "FIFO not supported");
|
||||
} else if (entry.metadata.type == MTAR_BLKDEV) {
|
||||
LOG_E(TAG, "BLKDEV not supported");
|
||||
} else if (entry.metadata.type == MTAR_CHRDEV) {
|
||||
LOG_E(TAG, "CHRDEV not supported");
|
||||
} else {
|
||||
LOG_E(TAG, "Unknown entry type: %d", static_cast<int>(entry.metadata.type));
|
||||
success = false;
|
||||
break;
|
||||
}
|
||||
} else break;
|
||||
} while (true);
|
||||
minitar_close(&mp);
|
||||
return success;
|
||||
}
|
||||
|
||||
void cleanupInstallDirectory(const std::string& path) {
|
||||
if (!file::deleteRecursively(path)) {
|
||||
LOG_W(TAG, "Failed to delete existing installation at %s", path.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
bool install(const std::string& path) {
|
||||
// We lock and unlock frequently because SPI SD card devices share
|
||||
// the lock with the display. We don't want to lock the display for very long.
|
||||
|
||||
auto app_parent_path = getAppInstallPath();
|
||||
LOG_I(TAG, "Installing app %s to %s", path.c_str(), app_parent_path.c_str());
|
||||
|
||||
auto filename = file::getLastPathSegment(path);
|
||||
const std::string app_target_path = std::format("{}/{}", app_parent_path, filename);
|
||||
if (file::isDirectory(app_target_path) && !file::deleteRecursively(app_target_path)) {
|
||||
LOG_W(TAG, "Failed to delete %s", app_target_path.c_str());
|
||||
}
|
||||
|
||||
if (!file::findOrCreateDirectory(app_target_path, 0777)) {
|
||||
LOG_I(TAG, "Failed to create directory %s", app_target_path.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
FileMutex target_path_mutex;
|
||||
file_mutex_get(&target_path_mutex, app_parent_path.c_str());
|
||||
FileMutex source_path_mutex;
|
||||
file_mutex_get(&source_path_mutex, path.c_str());
|
||||
|
||||
file_mutex_lock(&target_path_mutex);
|
||||
file_mutex_lock(&source_path_mutex);
|
||||
LOG_I(TAG, "Extracting app from %s to %s", path.c_str(), app_target_path.c_str());
|
||||
bool untar_success = untar(path, app_target_path);
|
||||
file_mutex_unlock(&source_path_mutex);
|
||||
file_mutex_unlock(&target_path_mutex);
|
||||
if (!untar_success) {
|
||||
LOG_E(TAG, "Failed to extract");
|
||||
return false;
|
||||
}
|
||||
|
||||
auto manifest_path = app_target_path + "/manifest.properties";
|
||||
if (!file::isFile(manifest_path)) {
|
||||
LOG_E(TAG, "Manifest not found at %s", manifest_path.c_str());
|
||||
cleanupInstallDirectory(app_target_path);
|
||||
return false;
|
||||
}
|
||||
|
||||
AppManifest manifest;
|
||||
if (!parseManifest(manifest_path, manifest)) {
|
||||
LOG_W(TAG, "Invalid manifest");
|
||||
cleanupInstallDirectory(app_target_path);
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the app was already running, then stop it
|
||||
if (isRunning(manifest.appId)) {
|
||||
stopAll(manifest.appId);
|
||||
}
|
||||
|
||||
const std::string renamed_target_path = std::format("{}/{}", app_parent_path, manifest.appId);
|
||||
if (file::isDirectory(renamed_target_path)) {
|
||||
if (!file::deleteRecursively(renamed_target_path)) {
|
||||
LOG_W(TAG, "Failed to delete existing installation at %s", renamed_target_path.c_str());
|
||||
cleanupInstallDirectory(app_target_path);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
file_mutex_lock(&target_path_mutex);
|
||||
bool rename_success = rename(app_target_path.c_str(), renamed_target_path.c_str()) == 0;
|
||||
file_mutex_unlock(&target_path_mutex);
|
||||
|
||||
if (!rename_success) {
|
||||
LOG_E(TAG, R"(Failed to rename "%s" to "%s")", app_target_path.c_str(), manifest.appId.c_str());
|
||||
cleanupInstallDirectory(app_target_path);
|
||||
return false;
|
||||
}
|
||||
|
||||
manifest.appLocation = Location::external(renamed_target_path);
|
||||
|
||||
addAppManifest(manifest);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool uninstall(const std::string& appId) {
|
||||
LOG_I(TAG, "Uninstalling app %s", appId.c_str());
|
||||
|
||||
// If the app was running, then stop it
|
||||
if (isRunning(appId)) {
|
||||
stopAll(appId);
|
||||
}
|
||||
|
||||
auto app_path = getAppInstallPath(appId);
|
||||
if (!file::isDirectory(app_path)) {
|
||||
LOG_E(TAG, "App %s not found at %s", appId.c_str(), app_path.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!file::deleteRecursively(app_path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!removeAppManifest(appId)) {
|
||||
LOG_W(TAG, "Failed to remove app %s from registry", appId.c_str());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -1,55 +0,0 @@
|
||||
#include <Tactility/app/AppInstance.h>
|
||||
#include <Tactility/app/AppPaths.h>
|
||||
|
||||
namespace tt::app {
|
||||
|
||||
void AppInstance::setState(State newState) {
|
||||
mutex.lock();
|
||||
state = newState;
|
||||
mutex.unlock();
|
||||
}
|
||||
|
||||
State AppInstance::getState() const {
|
||||
mutex.lock();
|
||||
auto result = state;
|
||||
mutex.unlock();
|
||||
return result;
|
||||
}
|
||||
|
||||
/** TODO: Make this thread-safe.
|
||||
* In practice, the bundle is writeable, so someone could be writing to it
|
||||
* while it is being accessed from another thread.
|
||||
* Consider creating MutableBundle vs Bundle.
|
||||
* Consider not exposing bundle, but expose `app_get_bundle_int(key)` methods with locking in it.
|
||||
*/
|
||||
const AppManifest& AppInstance::getManifest() const {
|
||||
assert(manifest != nullptr);
|
||||
return *manifest;
|
||||
}
|
||||
|
||||
Flags AppInstance::getFlags() const {
|
||||
mutex.lock();
|
||||
auto result = flags;
|
||||
mutex.unlock();
|
||||
return result;
|
||||
}
|
||||
|
||||
void AppInstance::setFlags(Flags newFlags) {
|
||||
mutex.lock();
|
||||
flags = newFlags;
|
||||
mutex.unlock();
|
||||
}
|
||||
|
||||
std::shared_ptr<const Bundle> AppInstance::getParameters() const {
|
||||
mutex.lock();
|
||||
std::shared_ptr<const Bundle> result = parameters;
|
||||
mutex.unlock();
|
||||
return result;
|
||||
}
|
||||
|
||||
std::unique_ptr<AppPaths> AppInstance::getPaths() const {
|
||||
assert(manifest != nullptr);
|
||||
return std::make_unique<AppPaths>(*manifest);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -1,97 +0,0 @@
|
||||
#include <Tactility/app/AppManifestParsing.h>
|
||||
#include <Tactility/app/AppManifestParsingInternal.h>
|
||||
|
||||
#include <Tactility/StringUtils.h>
|
||||
#include <Tactility/file/File.h>
|
||||
#include <Tactility/file/PropertiesFile.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <tactility/log.h>
|
||||
|
||||
namespace tt::app {
|
||||
|
||||
constexpr auto* TAG = "AppManifest";
|
||||
|
||||
constexpr bool validateString(const std::string& value, const std::function<bool(char)>& isValidChar) {
|
||||
return std::ranges::all_of(value, isValidChar);
|
||||
}
|
||||
|
||||
bool getValueFromManifest(const std::map<std::string, std::string>& map, const std::string& key, std::string& output) {
|
||||
const auto iterator = map.find(key);
|
||||
if (iterator == map.end()) {
|
||||
LOG_E(TAG, "Failed to find %s in manifest", key.c_str());
|
||||
return false;
|
||||
}
|
||||
output = iterator->second;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool isValidId(const std::string& id) {
|
||||
return id.size() >= 5 && validateString(id, [](const char c) {
|
||||
return std::isalnum(c) != 0 || c == '.';
|
||||
});
|
||||
}
|
||||
|
||||
bool isValidManifestVersion(const std::string& version) {
|
||||
return !version.empty() && validateString(version, [](const char c) {
|
||||
return std::isalnum(c) != 0 || c == '.';
|
||||
});
|
||||
}
|
||||
|
||||
bool isValidAppVersionName(const std::string& version) {
|
||||
return !version.empty() && validateString(version, [](const char c) {
|
||||
return std::isalnum(c) != 0 || c == '.' || c == '-' || c == '_';
|
||||
});
|
||||
}
|
||||
|
||||
bool isValidAppVersionCode(const std::string& version) {
|
||||
return !version.empty() && validateString(version, [](const char c) {
|
||||
return std::isdigit(c) != 0;
|
||||
});
|
||||
}
|
||||
|
||||
bool isValidName(const std::string& name) {
|
||||
return name.size() >= 2 && validateString(name, [](const char c) {
|
||||
return std::isalnum(c) != 0 || c == ' ' || c == '-';
|
||||
});
|
||||
}
|
||||
|
||||
/** The V1 format's first line is always the literal "[manifest]" section header; V2 files are flat from the first line onward. */
|
||||
static bool detectIsV1Format(const std::string& filePath) {
|
||||
std::string first_line;
|
||||
bool got_first_line = false;
|
||||
file::readLines(filePath, true, [&first_line, &got_first_line](const char* line) {
|
||||
if (!got_first_line) {
|
||||
first_line = string::trim(std::string(line), " \t\r\n");
|
||||
got_first_line = true;
|
||||
}
|
||||
});
|
||||
return first_line == "[manifest]";
|
||||
}
|
||||
|
||||
bool parseManifest(const std::string& filePath, AppManifest& manifest) {
|
||||
LOG_I(TAG, "Parsing manifest %s", filePath.c_str());
|
||||
|
||||
bool is_v1_format = detectIsV1Format(filePath);
|
||||
|
||||
std::map<std::string, std::string> properties;
|
||||
if (!file::loadPropertiesFile(filePath, properties)) {
|
||||
LOG_E(TAG, "Failed to load manifest at %s", filePath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
bool success = is_v1_format
|
||||
? parseManifestV1(properties, manifest)
|
||||
: parseManifestV2(properties, manifest);
|
||||
|
||||
if (!success) {
|
||||
return false;
|
||||
}
|
||||
|
||||
manifest.appCategory = Category::User;
|
||||
manifest.appLocation = Location::external("");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
#include <Tactility/app/AppManifestParsing.h>
|
||||
#include <Tactility/app/AppManifestParsingInternal.h>
|
||||
|
||||
#include <tactility/log.h>
|
||||
|
||||
namespace tt::app {
|
||||
|
||||
constexpr auto* TAG = "AppManifestV1";
|
||||
|
||||
bool parseManifestV1(const std::map<std::string, std::string>& map, AppManifest& manifest) {
|
||||
// [manifest]
|
||||
|
||||
std::string manifest_version;
|
||||
if (!getValueFromManifest(map, "[manifest]version", manifest_version)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isValidManifestVersion(manifest_version)) {
|
||||
LOG_E(TAG, "Invalid version");
|
||||
return false;
|
||||
}
|
||||
|
||||
// [app]
|
||||
|
||||
if (!getValueFromManifest(map, "[app]id", manifest.appId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isValidId(manifest.appId)) {
|
||||
LOG_E(TAG, "Invalid app id");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!getValueFromManifest(map, "[app]name", manifest.appName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isValidName(manifest.appName)) {
|
||||
LOG_E(TAG, "Invalid app name");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!getValueFromManifest(map, "[app]versionName", manifest.appVersionName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isValidAppVersionName(manifest.appVersionName)) {
|
||||
LOG_E(TAG, "Invalid app version name");
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string version_code_string;
|
||||
if (!getValueFromManifest(map, "[app]versionCode", version_code_string)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isValidAppVersionCode(version_code_string)) {
|
||||
LOG_E(TAG, "Invalid app version code");
|
||||
return false;
|
||||
}
|
||||
|
||||
manifest.appVersionCode = std::stoull(version_code_string);
|
||||
|
||||
// [target]
|
||||
|
||||
if (!getValueFromManifest(map, "[target]sdk", manifest.targetSdk)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!getValueFromManifest(map, "[target]platforms", manifest.targetPlatforms)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
#include <Tactility/app/AppManifestParsing.h>
|
||||
#include <Tactility/app/AppManifestParsingInternal.h>
|
||||
|
||||
#include <tactility/log.h>
|
||||
|
||||
namespace tt::app {
|
||||
|
||||
constexpr auto* TAG = "AppManifestV2";
|
||||
|
||||
bool parseManifestV2(const std::map<std::string, std::string>& map, AppManifest& manifest) {
|
||||
// manifest
|
||||
|
||||
std::string manifest_version;
|
||||
if (!getValueFromManifest(map, "manifest.version", manifest_version)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isValidManifestVersion(manifest_version)) {
|
||||
LOG_E(TAG, "Invalid version");
|
||||
return false;
|
||||
}
|
||||
|
||||
// app
|
||||
|
||||
if (!getValueFromManifest(map, "app.id", manifest.appId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isValidId(manifest.appId)) {
|
||||
LOG_E(TAG, "Invalid app id");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!getValueFromManifest(map, "app.name", manifest.appName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isValidName(manifest.appName)) {
|
||||
LOG_E(TAG, "Invalid app name");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!getValueFromManifest(map, "app.version.name", manifest.appVersionName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isValidAppVersionName(manifest.appVersionName)) {
|
||||
LOG_E(TAG, "Invalid app version name");
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string version_code_string;
|
||||
if (!getValueFromManifest(map, "app.version.code", version_code_string)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isValidAppVersionCode(version_code_string)) {
|
||||
LOG_E(TAG, "Invalid app version code");
|
||||
return false;
|
||||
}
|
||||
|
||||
manifest.appVersionCode = std::stoull(version_code_string);
|
||||
|
||||
// target
|
||||
|
||||
if (!getValueFromManifest(map, "target.sdk", manifest.targetSdk)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!getValueFromManifest(map, "target.platforms", manifest.targetPlatforms)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
#include <Tactility/app/AppPaths.h>
|
||||
|
||||
#include <Tactility/app/AppManifest.h>
|
||||
#include <Tactility/MountPoints.h>
|
||||
#include <Tactility/file/File.h>
|
||||
|
||||
#include <format>
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
constexpr auto PARTITION_PREFIX = std::string("/");
|
||||
#else
|
||||
constexpr auto PARTITION_PREFIX = std::string("");
|
||||
#endif
|
||||
|
||||
namespace tt::app {
|
||||
|
||||
std::string AppPaths::getUserDataPath() const {
|
||||
if (manifest.appLocation.isInternal()) {
|
||||
return std::format("{}{}/tactility/user/app/{}", PARTITION_PREFIX, file::DATA_PARTITION_NAME, manifest.appId);
|
||||
} else {
|
||||
return std::format("{}/tactility/user/app/{}", file::getFirstPathSegment(manifest.appLocation.getPath()), manifest.appId);
|
||||
}
|
||||
}
|
||||
|
||||
std::string AppPaths::getUserDataPath(const std::string& childPath) const {
|
||||
assert(!childPath.starts_with('/'));
|
||||
return std::format("{}/{}", getUserDataPath(), childPath);
|
||||
}
|
||||
|
||||
|
||||
std::string AppPaths::getAssetsPath() const {
|
||||
if (manifest.appLocation.isInternal()) {
|
||||
return std::format("{}{}/app/{}/assets", PARTITION_PREFIX, file::SYSTEM_PARTITION_NAME, manifest.appId);
|
||||
} else {
|
||||
return std::format("{}/assets", manifest.appLocation.getPath());
|
||||
}
|
||||
}
|
||||
|
||||
std::string AppPaths::getAssetsPath(const std::string& childPath) const {
|
||||
assert(!childPath.starts_with('/'));
|
||||
return std::format("{}/{}", getAssetsPath(), childPath);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
#include <Tactility/app/AppRegistration.h>
|
||||
#include <Tactility/app/AppManifest.h>
|
||||
|
||||
#include <Tactility/Mutex.h>
|
||||
|
||||
#include <unordered_map>
|
||||
#include <Tactility/file/File.h>
|
||||
#include <tactility/log.h>
|
||||
|
||||
namespace tt::app {
|
||||
|
||||
constexpr auto* TAG = "AppRegistration";
|
||||
|
||||
typedef std::unordered_map<std::string, std::shared_ptr<AppManifest>> AppManifestMap;
|
||||
|
||||
static AppManifestMap app_manifest_map;
|
||||
static Mutex hash_mutex;
|
||||
|
||||
void addAppManifest(const AppManifest& manifest) {
|
||||
LOG_I(TAG, "Registering manifest %s", manifest.appId.c_str());
|
||||
|
||||
hash_mutex.lock();
|
||||
|
||||
if (app_manifest_map.contains(manifest.appId)) {
|
||||
LOG_W(TAG, "Overwriting existing manifest for %s", manifest.appId.c_str());
|
||||
}
|
||||
|
||||
app_manifest_map[manifest.appId] = std::make_shared<AppManifest>(manifest);
|
||||
|
||||
hash_mutex.unlock();
|
||||
}
|
||||
|
||||
bool removeAppManifest(const std::string& id) {
|
||||
LOG_I(TAG, "Removing manifest for %s", id.c_str());
|
||||
|
||||
auto lock = hash_mutex.asScopedLock();
|
||||
lock.lock();
|
||||
|
||||
return app_manifest_map.erase(id) == 1;
|
||||
}
|
||||
|
||||
std::shared_ptr<AppManifest> findAppManifestById(const std::string& id) {
|
||||
hash_mutex.lock();
|
||||
auto result = app_manifest_map.find(id);
|
||||
hash_mutex.unlock();
|
||||
if (result != app_manifest_map.end()) {
|
||||
return result->second;
|
||||
} else {
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<std::shared_ptr<AppManifest>> getAppManifests() {
|
||||
std::vector<std::shared_ptr<AppManifest>> manifests;
|
||||
hash_mutex.lock();
|
||||
for (const auto& item: app_manifest_map) {
|
||||
manifests.push_back(item.second);
|
||||
}
|
||||
hash_mutex.unlock();
|
||||
return manifests;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -1,235 +0,0 @@
|
||||
#ifdef ESP_PLATFORM
|
||||
|
||||
#include <Tactility/app/alertdialog/AlertDialog.h>
|
||||
#include <Tactility/app/ElfApp.h>
|
||||
#include <Tactility/file/File.h>
|
||||
#include <Tactility/file/FileLock.h>
|
||||
#include <Tactility/service/loader/Loader.h>
|
||||
#include <Tactility/StringUtils.h>
|
||||
|
||||
#include <esp_elf.h>
|
||||
#include <string>
|
||||
#include <tactility/log.h>
|
||||
#include <utility>
|
||||
|
||||
namespace tt::app {
|
||||
|
||||
constexpr auto* TAG = "ElfApp";
|
||||
|
||||
static std::string getErrorCodeString(int error_code) {
|
||||
switch (error_code) {
|
||||
case ENOMEM:
|
||||
return "out of memory";
|
||||
case ENOSYS:
|
||||
return "missing symbol";
|
||||
case EINVAL:
|
||||
return "invalid argument or main() missing";
|
||||
default:
|
||||
return std::format("code {}", error_code);
|
||||
}
|
||||
}
|
||||
|
||||
class ElfApp final : public App {
|
||||
|
||||
public:
|
||||
|
||||
struct Parameters {
|
||||
CreateData createData = nullptr;
|
||||
DestroyData destroyData = nullptr;
|
||||
OnCreate onCreate = nullptr;
|
||||
OnDestroy onDestroy = nullptr;
|
||||
OnShow onShow = nullptr;
|
||||
OnHide onHide = nullptr;
|
||||
OnResult onResult = nullptr;
|
||||
};
|
||||
|
||||
static void setParameters(const Parameters& parameters) {
|
||||
staticParameters = parameters;
|
||||
staticParametersSetCount++;
|
||||
}
|
||||
|
||||
private:
|
||||
|
||||
static Parameters staticParameters;
|
||||
static size_t staticParametersSetCount;
|
||||
static std::shared_ptr<Lock> staticParametersLock;
|
||||
|
||||
const std::string appPath;
|
||||
std::unique_ptr<uint8_t[]> elfFileData;
|
||||
esp_elf_t elf {
|
||||
.psegment = nullptr,
|
||||
.svaddr = 0,
|
||||
.ptext = nullptr,
|
||||
.pdata = nullptr,
|
||||
.sec = { },
|
||||
.entry = nullptr
|
||||
};
|
||||
bool shouldCleanupElf = false; // Whether we have to clean up the above "elf" object
|
||||
std::unique_ptr<Parameters> manifest;
|
||||
void* data = nullptr;
|
||||
std::string lastError = "";
|
||||
|
||||
bool startElf() {
|
||||
const std::string elf_path = std::format("{}/elf/{}.elf", appPath, CONFIG_IDF_TARGET);
|
||||
LOG_I(TAG, "Starting ELF %s", elf_path.c_str());
|
||||
assert(elfFileData == nullptr);
|
||||
|
||||
size_t size = 0;
|
||||
{
|
||||
file::FileMutexGuard guard(elf_path);
|
||||
elfFileData = file::readBinary(elf_path, size);
|
||||
}
|
||||
|
||||
if (elfFileData == nullptr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (esp_elf_init(&elf) != ESP_OK) {
|
||||
lastError = "Failed to initialize";
|
||||
LOG_E(TAG, "%s", lastError.c_str());
|
||||
elfFileData = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
auto relocate_result = esp_elf_relocate(&elf, elfFileData.get());
|
||||
if (relocate_result != 0) {
|
||||
// Note: the result code maps to values from cstdlib's errno.h
|
||||
lastError = getErrorCodeString(-relocate_result);
|
||||
LOG_E(TAG, "Application failed to load: %s", lastError.c_str());
|
||||
esp_elf_deinit(&elf);
|
||||
elfFileData = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
int argc = 0;
|
||||
char* argv[] = {};
|
||||
|
||||
if (esp_elf_request(&elf, 0, argc, argv) != ESP_OK) {
|
||||
lastError = "Executable returned error code";
|
||||
LOG_E(TAG, "%s", lastError.c_str());
|
||||
esp_elf_deinit(&elf);
|
||||
elfFileData = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
shouldCleanupElf = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void stopElf() {
|
||||
LOG_I(TAG, "Cleaning up ELF");
|
||||
|
||||
if (shouldCleanupElf) {
|
||||
esp_elf_deinit(&elf);
|
||||
}
|
||||
|
||||
if (elfFileData != nullptr) {
|
||||
elfFileData = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
|
||||
explicit ElfApp(std::string appPath) : appPath(std::move(appPath)) {}
|
||||
|
||||
void onCreate(AppContext& appContext) override {
|
||||
// Because we use global variables, we have to ensure that we are not starting 2 apps in parallel
|
||||
// We use a ScopedLock so we don't have to safeguard all branches
|
||||
auto lock = staticParametersLock->asScopedLock();
|
||||
lock.lock();
|
||||
|
||||
staticParametersSetCount = 0;
|
||||
if (!startElf()) {
|
||||
stop();
|
||||
auto message = lastError.empty() ? "Application failed to start." : std::format("Application failed to start: {}", lastError);
|
||||
alertdialog::start("Error", message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (staticParametersSetCount == 0) {
|
||||
stop();
|
||||
alertdialog::start("Error", "Application failed to start: application failed to register itself");
|
||||
return;
|
||||
}
|
||||
|
||||
manifest = std::make_unique<Parameters>(staticParameters);
|
||||
lock.unlock();
|
||||
|
||||
if (manifest->createData != nullptr) {
|
||||
data = manifest->createData();
|
||||
}
|
||||
|
||||
if (manifest->onCreate != nullptr) {
|
||||
manifest->onCreate(&appContext, data);
|
||||
}
|
||||
}
|
||||
|
||||
void onDestroy(AppContext& appContext) override {
|
||||
LOG_I(TAG, "Cleaning up app");
|
||||
if (manifest != nullptr) {
|
||||
if (manifest->onDestroy != nullptr) {
|
||||
manifest->onDestroy(&appContext, data);
|
||||
}
|
||||
|
||||
if (manifest->destroyData != nullptr && data != nullptr) {
|
||||
manifest->destroyData(data);
|
||||
}
|
||||
|
||||
this->manifest = nullptr;
|
||||
}
|
||||
stopElf();
|
||||
}
|
||||
|
||||
void onShow(AppContext& appContext, lv_obj_t* parent) override {
|
||||
if (manifest != nullptr && manifest->onShow != nullptr) {
|
||||
manifest->onShow(&appContext, data, parent);
|
||||
}
|
||||
}
|
||||
|
||||
void onHide(AppContext& appContext) override {
|
||||
if (manifest != nullptr && manifest->onHide != nullptr) {
|
||||
manifest->onHide(&appContext, data);
|
||||
}
|
||||
}
|
||||
|
||||
void onResult(AppContext& appContext, LaunchId launchId, Result result, std::unique_ptr<Bundle> resultBundle) override {
|
||||
if (manifest != nullptr && manifest->onResult != nullptr) {
|
||||
manifest->onResult(&appContext, data, launchId, result, resultBundle.get());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ElfApp::Parameters ElfApp::staticParameters;
|
||||
size_t ElfApp::staticParametersSetCount = 0;
|
||||
std::shared_ptr<Lock> ElfApp::staticParametersLock = std::make_shared<Mutex>();
|
||||
|
||||
void setElfAppParameters(
|
||||
CreateData createData,
|
||||
DestroyData destroyData,
|
||||
OnCreate onCreate,
|
||||
OnDestroy onDestroy,
|
||||
OnShow onShow,
|
||||
OnHide onHide,
|
||||
OnResult onResult
|
||||
) {
|
||||
ElfApp::setParameters({
|
||||
.createData = createData,
|
||||
.destroyData = destroyData,
|
||||
.onCreate = onCreate,
|
||||
.onDestroy = onDestroy,
|
||||
.onShow = onShow,
|
||||
.onHide = onHide,
|
||||
.onResult = onResult
|
||||
});
|
||||
}
|
||||
|
||||
std::shared_ptr<App> createElfApp(const std::shared_ptr<AppManifest>& manifest) {
|
||||
LOG_I(TAG, "createElfApp");
|
||||
assert(manifest != nullptr);
|
||||
assert(manifest->appLocation.isExternal());
|
||||
return std::make_shared<ElfApp>(manifest->appLocation.getPath());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
#endif // ESP_PLATFORM
|
||||
@@ -1,10 +1,15 @@
|
||||
#include <Tactility/StringUtils.h>
|
||||
#include <Tactility/app/AppManifest.h>
|
||||
#include <Tactility/app/alertdialog/AlertDialog.h>
|
||||
#include <Tactility/lvgl/Style.h>
|
||||
#include <Tactility/lvgl/Toolbar.h>
|
||||
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <lvgl/icons/shared.h>
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
#include <tactility/drivers/uart_controller.h>
|
||||
#include <tactility/log.h>
|
||||
|
||||
@@ -18,8 +23,12 @@ namespace tt::app::addgps {
|
||||
|
||||
constexpr auto* TAG = "AddGps";
|
||||
|
||||
class AddGpsApp final : public App {
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
namespace {
|
||||
|
||||
struct Context {
|
||||
uint32_t appInstanceId;
|
||||
lv_obj_t* uartDropdown = nullptr;
|
||||
lv_obj_t* modelDropdown = nullptr;
|
||||
lv_obj_t* baudDropdown = nullptr;
|
||||
@@ -30,168 +39,209 @@ class AddGpsApp final : public App {
|
||||
// We only need to parse back to int when adding the new GPS entry
|
||||
std::array<uint32_t, 6> baudRates = { 9600, 19200, 28800, 38400, 57600, 115200 };
|
||||
const char* baudRatesDropdownValues = "9600\n19200\n28800\n38400\n57600\n115200";
|
||||
|
||||
static std::vector<std::string> getModelNames() {
|
||||
std::vector<std::string> result;
|
||||
for (int model = GpsModel::GPS_MODEL_UNKNOWN; model <= GpsModel::GPS_MODEL_UC6580; model++) {
|
||||
result.emplace_back(gps_model_to_string(static_cast<GpsModel>(model)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static void onAddGpsCallback(lv_event_t* event) {
|
||||
auto* app = (AddGpsApp*)lv_event_get_user_data(event);
|
||||
app->onAddGps();
|
||||
}
|
||||
|
||||
void onAddGps() {
|
||||
auto selected_baud_index = lv_dropdown_get_selected(baudDropdown);
|
||||
|
||||
GpsConfiguration new_configuration = {
|
||||
.uart_name = { 0x00 },
|
||||
.baud_rate = baudRates[selected_baud_index],
|
||||
// Warning: This assumes that the enum is a regularly indexed one that starts at 0
|
||||
.model = (GpsModel)lv_dropdown_get_selected(modelDropdown)
|
||||
};
|
||||
|
||||
lv_dropdown_get_selected_str(uartDropdown, new_configuration.uart_name, sizeof(new_configuration.uart_name));
|
||||
if (new_configuration.uart_name[0] == 0x00) {
|
||||
alertdialog::start("Error", "You must select a bus/uart.");
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_I(TAG, "Saving: uart=%s, model=%d, baud=%u", new_configuration.uart_name, (int)new_configuration.model, (unsigned)new_configuration.baud_rate);
|
||||
if (gps_settings_add_configuration(&new_configuration) != ERROR_NONE) {
|
||||
alertdialog::start("Error", "Failed to add configuration");
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
void updateUartDevices() {
|
||||
devices.clear();
|
||||
device_for_each_of_type(&UART_CONTROLLER_TYPE, &devices, [](auto* device, auto* context){
|
||||
auto* vector_ptr = static_cast<std::vector<::Device*>*>(context);
|
||||
vector_ptr->push_back(device);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
std::string getUartDropdownNames() {
|
||||
std::vector<std::string> names;
|
||||
names.push_back("");
|
||||
for (auto* device: devices) {
|
||||
names.push_back(device->name);
|
||||
}
|
||||
return string::join(names, "\n");
|
||||
}
|
||||
|
||||
public:
|
||||
|
||||
void onShow(AppContext& app, lv_obj_t* parent) final {
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
lvgl::toolbar_create(parent, app);
|
||||
|
||||
auto* main_wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_width(main_wrapper, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(main_wrapper, 1);
|
||||
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_all(main_wrapper, 0, 0);
|
||||
lv_obj_set_style_border_width(main_wrapper, 0, 0);
|
||||
lvgl::obj_set_style_bg_invisible(main_wrapper);
|
||||
|
||||
// region Uart
|
||||
|
||||
auto* uart_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(uart_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_ver(uart_wrapper, 0, 0);
|
||||
lv_obj_set_style_border_width(uart_wrapper, 0, 0);
|
||||
lvgl::obj_set_style_bg_invisible(uart_wrapper);
|
||||
|
||||
uartDropdown = lv_dropdown_create(uart_wrapper);
|
||||
|
||||
updateUartDevices();
|
||||
|
||||
auto uart_options = getUartDropdownNames();
|
||||
lv_dropdown_set_options(uartDropdown, uart_options.c_str());
|
||||
lv_obj_align(uartDropdown, LV_ALIGN_TOP_RIGHT, 0, 0);
|
||||
lv_obj_set_width(uartDropdown, LV_PCT(50));
|
||||
|
||||
auto* uart_label = lv_label_create(uart_wrapper);
|
||||
lv_obj_align(uart_label, LV_ALIGN_TOP_LEFT, 0, 10);
|
||||
lv_label_set_text(uart_label, "Bus");
|
||||
|
||||
// region Model
|
||||
|
||||
auto* model_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(model_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_ver(model_wrapper, 0, 0);
|
||||
lv_obj_set_style_border_width(model_wrapper, 0, 0);
|
||||
lvgl::obj_set_style_bg_invisible(model_wrapper);
|
||||
|
||||
modelDropdown = lv_dropdown_create(model_wrapper);
|
||||
|
||||
auto model_names = getModelNames();
|
||||
auto model_options = string::join(model_names, "\n");
|
||||
lv_dropdown_set_options(modelDropdown, model_options.c_str());
|
||||
lv_obj_align(modelDropdown, LV_ALIGN_TOP_RIGHT, 0, 0);
|
||||
lv_obj_set_width(modelDropdown, LV_PCT(50));
|
||||
|
||||
auto* model_label = lv_label_create(model_wrapper);
|
||||
lv_obj_align(model_label, LV_ALIGN_TOP_LEFT, 0, 10);
|
||||
lv_label_set_text(model_label, "Model");
|
||||
|
||||
// endregion
|
||||
|
||||
// region Baud
|
||||
auto* baud_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(baud_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_ver(baud_wrapper, 0, 0);
|
||||
lv_obj_set_style_border_width(baud_wrapper, 0, 0);
|
||||
lvgl::obj_set_style_bg_invisible(baud_wrapper);
|
||||
|
||||
baudDropdown = lv_dropdown_create(baud_wrapper);
|
||||
lv_dropdown_set_options(baudDropdown, baudRatesDropdownValues);
|
||||
lv_obj_align(baudDropdown, LV_ALIGN_TOP_RIGHT, 0, 0);
|
||||
lv_obj_set_width(baudDropdown, LV_PCT(50));
|
||||
|
||||
auto* baud_rate_label = lv_label_create(baud_wrapper);
|
||||
lv_obj_align(baud_rate_label, LV_ALIGN_TOP_LEFT, 0, 10);
|
||||
lv_label_set_text(baud_rate_label, "Baud");
|
||||
|
||||
// endregion
|
||||
|
||||
// region Button
|
||||
|
||||
auto* button_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(button_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_ver(button_wrapper, 0, 0);
|
||||
lv_obj_set_style_border_width(button_wrapper, 0, 0);
|
||||
lvgl::obj_set_style_bg_invisible(button_wrapper);
|
||||
|
||||
auto* add_button = lv_button_create(button_wrapper);
|
||||
lv_obj_align(add_button, LV_ALIGN_TOP_MID, 0, 0);
|
||||
auto* add_label = lv_label_create(add_button);
|
||||
lv_label_set_text(add_label, "Add");
|
||||
lv_obj_add_event_cb(add_button, onAddGpsCallback, LV_EVENT_SHORT_CLICKED, this);
|
||||
|
||||
// endregion
|
||||
}
|
||||
};
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "AddGps",
|
||||
.appName = "Add GPS",
|
||||
.appIcon = LVGL_ICON_SHARED_NAVIGATION,
|
||||
.appCategory = Category::System,
|
||||
.appFlags = AppManifest::Flags::Hidden,
|
||||
.createApp = create<AddGpsApp>
|
||||
};
|
||||
|
||||
void start() {
|
||||
app::start(manifest.appId);
|
||||
std::vector<std::string> getModelNames() {
|
||||
std::vector<std::string> result;
|
||||
for (int model = GpsModel::GPS_MODEL_UNKNOWN; model <= GpsModel::GPS_MODEL_UC6580; model++) {
|
||||
result.emplace_back(gps_model_to_string(static_cast<GpsModel>(model)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void onBackPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
|
||||
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
|
||||
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
|
||||
// deadlock against itself.
|
||||
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(ctx->appInstanceId, &closeEvent);
|
||||
}
|
||||
|
||||
void onAddGpsPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
auto selected_baud_index = lv_dropdown_get_selected(ctx->baudDropdown);
|
||||
|
||||
GpsConfiguration new_configuration = {
|
||||
.uart_name = { 0x00 },
|
||||
.baud_rate = ctx->baudRates[selected_baud_index],
|
||||
// Warning: This assumes that the enum is a regularly indexed one that starts at 0
|
||||
.model = (GpsModel)lv_dropdown_get_selected(ctx->modelDropdown)
|
||||
};
|
||||
|
||||
lv_dropdown_get_selected_str(ctx->uartDropdown, new_configuration.uart_name, sizeof(new_configuration.uart_name));
|
||||
if (new_configuration.uart_name[0] == 0x00) {
|
||||
alertdialog::start(ctx->appInstanceId, "Error", "You must select a bus/uart.");
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_I(TAG, "Saving: uart=%s, model=%d, baud=%u", new_configuration.uart_name, (int)new_configuration.model, (unsigned)new_configuration.baud_rate);
|
||||
if (gps_settings_add_configuration(&new_configuration) != ERROR_NONE) {
|
||||
alertdialog::start(ctx->appInstanceId, "Error", "Failed to add configuration");
|
||||
} else {
|
||||
onBackPressed(event);
|
||||
}
|
||||
}
|
||||
|
||||
void updateUartDevices(Context* ctx) {
|
||||
ctx->devices.clear();
|
||||
device_for_each_of_type(&UART_CONTROLLER_TYPE, &ctx->devices, [](auto* device, auto* context) {
|
||||
auto* vector_ptr = static_cast<std::vector<::Device*>*>(context);
|
||||
vector_ptr->push_back(device);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
std::string getUartDropdownNames(Context* ctx) {
|
||||
std::vector<std::string> names;
|
||||
names.push_back("");
|
||||
for (auto* device: ctx->devices) {
|
||||
names.push_back(device->name);
|
||||
}
|
||||
return string::join(names, "\n");
|
||||
}
|
||||
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* toolbar = lvgl_toolbar_create(parent, "Add GPS");
|
||||
// The global toolbar nav callback only knows how to stop old-model apps.
|
||||
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
|
||||
|
||||
auto* main_wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_width(main_wrapper, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(main_wrapper, 1);
|
||||
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_all(main_wrapper, 0, 0);
|
||||
lv_obj_set_style_border_width(main_wrapper, 0, 0);
|
||||
lvgl::obj_set_style_bg_invisible(main_wrapper);
|
||||
|
||||
// region Uart
|
||||
|
||||
auto* uart_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(uart_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_ver(uart_wrapper, 0, 0);
|
||||
lv_obj_set_style_border_width(uart_wrapper, 0, 0);
|
||||
lvgl::obj_set_style_bg_invisible(uart_wrapper);
|
||||
|
||||
ctx->uartDropdown = lv_dropdown_create(uart_wrapper);
|
||||
|
||||
updateUartDevices(ctx);
|
||||
|
||||
auto uart_options = getUartDropdownNames(ctx);
|
||||
lv_dropdown_set_options(ctx->uartDropdown, uart_options.c_str());
|
||||
lv_obj_align(ctx->uartDropdown, LV_ALIGN_TOP_RIGHT, 0, 0);
|
||||
lv_obj_set_width(ctx->uartDropdown, LV_PCT(50));
|
||||
|
||||
auto* uart_label = lv_label_create(uart_wrapper);
|
||||
lv_obj_align(uart_label, LV_ALIGN_TOP_LEFT, 0, 10);
|
||||
lv_label_set_text(uart_label, "Bus");
|
||||
|
||||
// region Model
|
||||
|
||||
auto* model_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(model_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_ver(model_wrapper, 0, 0);
|
||||
lv_obj_set_style_border_width(model_wrapper, 0, 0);
|
||||
lvgl::obj_set_style_bg_invisible(model_wrapper);
|
||||
|
||||
ctx->modelDropdown = lv_dropdown_create(model_wrapper);
|
||||
|
||||
auto model_names = getModelNames();
|
||||
auto model_options = string::join(model_names, "\n");
|
||||
lv_dropdown_set_options(ctx->modelDropdown, model_options.c_str());
|
||||
lv_obj_align(ctx->modelDropdown, LV_ALIGN_TOP_RIGHT, 0, 0);
|
||||
lv_obj_set_width(ctx->modelDropdown, LV_PCT(50));
|
||||
|
||||
auto* model_label = lv_label_create(model_wrapper);
|
||||
lv_obj_align(model_label, LV_ALIGN_TOP_LEFT, 0, 10);
|
||||
lv_label_set_text(model_label, "Model");
|
||||
|
||||
// endregion
|
||||
|
||||
// region Baud
|
||||
auto* baud_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(baud_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_ver(baud_wrapper, 0, 0);
|
||||
lv_obj_set_style_border_width(baud_wrapper, 0, 0);
|
||||
lvgl::obj_set_style_bg_invisible(baud_wrapper);
|
||||
|
||||
ctx->baudDropdown = lv_dropdown_create(baud_wrapper);
|
||||
lv_dropdown_set_options(ctx->baudDropdown, ctx->baudRatesDropdownValues);
|
||||
lv_obj_align(ctx->baudDropdown, LV_ALIGN_TOP_RIGHT, 0, 0);
|
||||
lv_obj_set_width(ctx->baudDropdown, LV_PCT(50));
|
||||
|
||||
auto* baud_rate_label = lv_label_create(baud_wrapper);
|
||||
lv_obj_align(baud_rate_label, LV_ALIGN_TOP_LEFT, 0, 10);
|
||||
lv_label_set_text(baud_rate_label, "Baud");
|
||||
|
||||
// endregion
|
||||
|
||||
// region Button
|
||||
|
||||
auto* button_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(button_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_ver(button_wrapper, 0, 0);
|
||||
lv_obj_set_style_border_width(button_wrapper, 0, 0);
|
||||
lvgl::obj_set_style_bg_invisible(button_wrapper);
|
||||
|
||||
auto* add_button = lv_button_create(button_wrapper);
|
||||
lv_obj_align(add_button, LV_ALIGN_TOP_MID, 0, 0);
|
||||
auto* add_label = lv_label_create(add_button);
|
||||
lv_label_set_text(add_label, "Add");
|
||||
lv_obj_add_event_cb(add_button, onAddGpsPressed, LV_EVENT_SHORT_CLICKED, ctx);
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
Context ctx {};
|
||||
ctx.appInstanceId = appInstanceId;
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
|
||||
bool shouldClose = false;
|
||||
while (!shouldClose) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
switch (event.type) {
|
||||
case APP_EVENT_CLOSE:
|
||||
app_manager_finish(appInstanceId);
|
||||
shouldClose = true;
|
||||
break;
|
||||
case APP_EVENT_RESULT:
|
||||
app_manager_stop(event.result.launch_id);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "AddGps",
|
||||
.name = "Add GPS",
|
||||
.category = APP_CATEGORY_SYSTEM,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
|
||||
.flags = APP_MANIFEST_FLAG_HIDDEN,
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
#include "Tactility/app/alertdialog/AlertDialog.h"
|
||||
|
||||
#include <Tactility/service/loader/Loader.h>
|
||||
#include <Tactility/StringUtils.h>
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <tactility/log.h>
|
||||
|
||||
@@ -10,133 +13,150 @@
|
||||
|
||||
namespace tt::app::alertdialog {
|
||||
|
||||
#define PARAMETER_BUNDLE_KEY_TITLE "title"
|
||||
#define PARAMETER_BUNDLE_KEY_MESSAGE "message"
|
||||
#define PARAMETER_BUNDLE_KEY_BUTTON_LABELS "buttonLabels"
|
||||
#define RESULT_BUNDLE_KEY_INDEX "index"
|
||||
|
||||
#define PARAMETER_ITEM_CONCATENATION_TOKEN ";;"
|
||||
#define DEFAULT_TITLE ""
|
||||
|
||||
constexpr auto* TAG = "AlertDialog";
|
||||
|
||||
extern const AppManifest manifest;
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
LaunchId start(const std::string& title, const std::string& message, const std::vector<std::string>& buttonLabels) {
|
||||
std::string items_joined = string::join(buttonLabels, PARAMETER_ITEM_CONCATENATION_TOKEN);
|
||||
auto bundle = std::make_shared<Bundle>();
|
||||
bundle->putString(PARAMETER_BUNDLE_KEY_TITLE, title);
|
||||
bundle->putString(PARAMETER_BUNDLE_KEY_MESSAGE, message);
|
||||
bundle->putString(PARAMETER_BUNDLE_KEY_BUTTON_LABELS, items_joined);
|
||||
return app::start(manifest.appId, bundle);
|
||||
}
|
||||
namespace {
|
||||
|
||||
LaunchId start(const std::string& title, const std::string& message, const std::vector<const char*>& buttonLabels) {
|
||||
std::string items_joined = string::join(buttonLabels, PARAMETER_ITEM_CONCATENATION_TOKEN);
|
||||
auto bundle = std::make_shared<Bundle>();
|
||||
bundle->putString(PARAMETER_BUNDLE_KEY_TITLE, title);
|
||||
bundle->putString(PARAMETER_BUNDLE_KEY_MESSAGE, message);
|
||||
bundle->putString(PARAMETER_BUNDLE_KEY_BUTTON_LABELS, items_joined);
|
||||
return app::start(manifest.appId, bundle);
|
||||
}
|
||||
|
||||
LaunchId start(const std::string& title, const std::string& message) {
|
||||
auto bundle = std::make_shared<Bundle>();
|
||||
bundle->putString(PARAMETER_BUNDLE_KEY_TITLE, title);
|
||||
bundle->putString(PARAMETER_BUNDLE_KEY_MESSAGE, message);
|
||||
bundle->putString(PARAMETER_BUNDLE_KEY_BUTTON_LABELS, "OK");
|
||||
return app::start(manifest.appId, bundle);
|
||||
}
|
||||
|
||||
int32_t getResultIndex(const Bundle& bundle) {
|
||||
int32_t index = -1;
|
||||
bundle.optInt32(RESULT_BUNDLE_KEY_INDEX, index);
|
||||
return index;
|
||||
}
|
||||
|
||||
static std::string getTitleParameter(std::shared_ptr<const Bundle> bundle) {
|
||||
std::string result;
|
||||
if (bundle->optString(PARAMETER_BUNDLE_KEY_TITLE, result)) {
|
||||
return result;
|
||||
} else {
|
||||
return DEFAULT_TITLE;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AlertDialogApp : public App {
|
||||
|
||||
static void onButtonClickedCallback(lv_event_t* e) {
|
||||
auto app = std::static_pointer_cast<AlertDialogApp>(getCurrentApp());
|
||||
assert(app != nullptr);
|
||||
app->onButtonClicked(e);
|
||||
}
|
||||
|
||||
void onButtonClicked(lv_event_t* e) {
|
||||
auto index = reinterpret_cast<std::size_t>(lv_event_get_user_data(e));
|
||||
LOG_I(TAG, "Selected item at index %d", (int)index);
|
||||
|
||||
auto bundle = std::make_unique<Bundle>();
|
||||
bundle->putInt32(RESULT_BUNDLE_KEY_INDEX, (int32_t)index);
|
||||
setResult(Result::Ok, std::move(bundle));
|
||||
|
||||
stop(manifest.appId);
|
||||
}
|
||||
|
||||
static void createButton(lv_obj_t* parent, const std::string& text, size_t index) {
|
||||
lv_obj_t* button = lv_button_create(parent);
|
||||
lv_obj_t* button_label = lv_label_create(button);
|
||||
lv_obj_align(button_label, LV_ALIGN_CENTER, 0, 0);
|
||||
lv_label_set_text(button_label, text.c_str());
|
||||
lv_obj_add_event_cb(button, onButtonClickedCallback, LV_EVENT_SHORT_CLICKED, (void*)index);
|
||||
}
|
||||
|
||||
public:
|
||||
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override {
|
||||
auto parameters = app.getParameters();
|
||||
check(parameters != nullptr, "Parameters missing");
|
||||
|
||||
std::string title = getTitleParameter(app.getParameters());
|
||||
lv_obj_t* toolbar = lvgl_toolbar_create(parent, title.c_str());
|
||||
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||
|
||||
lv_obj_t* message_label = lv_label_create(parent);
|
||||
lv_obj_align(message_label, LV_ALIGN_CENTER, 0, 0);
|
||||
lv_obj_set_width(message_label, LV_PCT(80));
|
||||
lv_obj_set_style_text_align(message_label, LV_TEXT_ALIGN_CENTER, 0);
|
||||
|
||||
std::string message;
|
||||
if (parameters->optString(PARAMETER_BUNDLE_KEY_MESSAGE, message)) {
|
||||
lv_label_set_text(message_label, message.c_str());
|
||||
lv_label_set_long_mode(message_label, LV_LABEL_LONG_WRAP);
|
||||
}
|
||||
|
||||
lv_obj_t* button_wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_flex_flow(button_wrapper, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_size(button_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(button_wrapper, 0, 0);
|
||||
lv_obj_set_flex_align(button_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_border_width(button_wrapper, 0, 0);
|
||||
lv_obj_align(button_wrapper, LV_ALIGN_BOTTOM_MID, 0, -4);
|
||||
|
||||
std::string items_concatenated;
|
||||
if (parameters->optString(PARAMETER_BUNDLE_KEY_BUTTON_LABELS, items_concatenated)) {
|
||||
std::vector<std::string> labels = string::split(items_concatenated, PARAMETER_ITEM_CONCATENATION_TOKEN);
|
||||
size_t index = 0;
|
||||
for (const auto& label: labels) {
|
||||
createButton(button_wrapper, label, index++);
|
||||
}
|
||||
}
|
||||
}
|
||||
struct Context {
|
||||
uint32_t appInstanceId;
|
||||
// Set once in appMain() from its own argc/argv parameters, read by createWidgets() (which
|
||||
// may run on a different task - the LVGL task, or another app's task via
|
||||
// window_manager_remove()'s cross-thread rebuild-on-remove path). Safe to hold onto without a
|
||||
// lock: the deep copy stays valid for exactly as long as appMain() is running, which is
|
||||
// longer than createWidgets() ever needs it.
|
||||
int argc = 0;
|
||||
char** argv = nullptr;
|
||||
// The eventual appMain() return value (= this dialog's APP_EVENT_RESULT result code) -
|
||||
// written here by onButtonPressed() (LVGL thread) before it emits APP_EVENT_CLOSE, read by
|
||||
// appMain() (this dialog's own thread) after waking from that event. No atomic/lock needed:
|
||||
// the emit/await pair between the two already establishes happens-before ordering, same as
|
||||
// every other cross-thread Context field write in this codebase's converted apps.
|
||||
int32_t result = 1; // Cancelled - safety-net default if closed without pressing a button
|
||||
};
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "AlertDialog",
|
||||
.appName = "Alert Dialog",
|
||||
.appCategory = Category::System,
|
||||
.appFlags = AppManifest::Flags::Hidden,
|
||||
.createApp = create<AlertDialogApp>
|
||||
struct ButtonContext {
|
||||
Context* ctx;
|
||||
int32_t index;
|
||||
};
|
||||
|
||||
void onButtonDeleted(lv_event_t* e) {
|
||||
delete static_cast<ButtonContext*>(lv_event_get_user_data(e));
|
||||
}
|
||||
|
||||
void onButtonPressed(lv_event_t* e) {
|
||||
auto* btnCtx = static_cast<ButtonContext*>(lv_event_get_user_data(e));
|
||||
LOG_I(TAG, "Selected item at index %d", (int)btnCtx->index);
|
||||
btnCtx->ctx->result = btnCtx->index;
|
||||
// Async, non-blocking - just wakes this dialog's own thread. Must NOT call
|
||||
// app_manager_stop() here: that bound-waits (thread_join) for the dialog's thread to
|
||||
// finish, which needs the LVGL lock (window_manager_remove()) - but this callback is
|
||||
// running ON the LVGL task, which would deadlock against itself. The caller reaps this
|
||||
// instance via app_manager_stop() after it receives the APP_EVENT_RESULT instead.
|
||||
AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(btnCtx->ctx->appInstanceId, &event);
|
||||
}
|
||||
|
||||
void createButton(Context* ctx, lv_obj_t* parent, const std::string& text, int32_t index) {
|
||||
lv_obj_t* button = lv_button_create(parent);
|
||||
lv_obj_t* button_label = lv_label_create(button);
|
||||
lv_obj_align(button_label, LV_ALIGN_CENTER, 0, 0);
|
||||
lv_label_set_text(button_label, text.c_str());
|
||||
auto* btnCtx = new ButtonContext { ctx, index };
|
||||
lv_obj_add_event_cb(button, onButtonPressed, LV_EVENT_SHORT_CLICKED, btnCtx);
|
||||
lv_obj_add_event_cb(button, onButtonDeleted, LV_EVENT_DELETE, btnCtx);
|
||||
}
|
||||
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
// argv layout: [0]=title, [1]=message, [2..argc)=button labels.
|
||||
int argc = ctx->argc;
|
||||
char** argv = ctx->argv;
|
||||
|
||||
lv_obj_t* toolbar = lvgl_toolbar_create(parent, argv[0]);
|
||||
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||
|
||||
lv_obj_t* message_label = lv_label_create(parent);
|
||||
lv_obj_align(message_label, LV_ALIGN_CENTER, 0, 0);
|
||||
lv_obj_set_width(message_label, LV_PCT(80));
|
||||
lv_obj_set_style_text_align(message_label, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_label_set_text(message_label, argv[1]);
|
||||
lv_label_set_long_mode(message_label, LV_LABEL_LONG_WRAP);
|
||||
|
||||
lv_obj_t* button_wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_flex_flow(button_wrapper, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_size(button_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(button_wrapper, 0, 0);
|
||||
lv_obj_set_flex_align(button_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_border_width(button_wrapper, 0, 0);
|
||||
lv_obj_align(button_wrapper, LV_ALIGN_BOTTOM_MID, 0, -4);
|
||||
|
||||
for (int32_t index = 0; index < argc - 2; index++) {
|
||||
createButton(ctx, button_wrapper, argv[2 + index], index);
|
||||
}
|
||||
}
|
||||
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
Context ctx { appInstanceId };
|
||||
ctx.argc = argc;
|
||||
ctx.argv = argv;
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
|
||||
while (true) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
if (event.type == APP_EVENT_CLOSE) {
|
||||
app_manager_finish(appInstanceId); // no-op: modal children never supersede anything
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
return ctx.result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace {
|
||||
|
||||
// Builds argv = [title, message, buttonLabels...] for app_manager_start_for_result().
|
||||
std::vector<const char*> buildArgv(const std::string& title, const std::string& message, const std::vector<std::string>& buttonLabels) {
|
||||
std::vector<const char*> argv { title.c_str(), message.c_str() };
|
||||
for (const auto& label: buttonLabels) {
|
||||
argv.push_back(label.c_str());
|
||||
}
|
||||
return argv;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
uint32_t start(uint32_t callerAppInstanceId, const std::string& title, const std::string& message, const std::vector<std::string>& buttonLabels) {
|
||||
auto argv = buildArgv(title, message, buttonLabels);
|
||||
uint32_t instanceId = 0;
|
||||
app_manager_start_for_result(manifest.id, callerAppInstanceId, static_cast<int>(argv.size()), argv.data(), &instanceId);
|
||||
return instanceId;
|
||||
}
|
||||
|
||||
uint32_t start(uint32_t callerAppInstanceId, const std::string& title, const std::string& message) {
|
||||
return start(callerAppInstanceId, title, message, std::vector<std::string> { "OK" });
|
||||
}
|
||||
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "AlertDialog",
|
||||
.name = "Alert Dialog",
|
||||
.category = APP_CATEGORY_SYSTEM,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
|
||||
.flags = APP_MANIFEST_FLAG_HIDDEN,
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@@ -1,114 +1,168 @@
|
||||
#include <Tactility/app/App.h>
|
||||
#include <Tactility/app/AppContext.h>
|
||||
#include <Tactility/app/AppManifest.h>
|
||||
#include <Tactility/app/alertdialog/AlertDialog.h>
|
||||
#include <Tactility/lvgl/Style.h>
|
||||
#include <Tactility/StringUtils.h>
|
||||
#include <Tactility/file/File.h>
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
#include <app/install.h>
|
||||
|
||||
#include <tactility/check.h>
|
||||
#include <format>
|
||||
|
||||
#include <lvgl/lvgl.h>
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
|
||||
#include <format>
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <Tactility/StringUtils.h>
|
||||
#include <Tactility/app/alertdialog/AlertDialog.h>
|
||||
#include <Tactility/file/File.h>
|
||||
#include <Tactility/lvgl/Style.h>
|
||||
|
||||
#include <tactility/log.h>
|
||||
|
||||
constexpr auto* TAG = "AppDetails";
|
||||
|
||||
namespace tt::app::appdetails {
|
||||
|
||||
extern const AppManifest manifest;
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
void start(const std::string& appId) {
|
||||
auto bundle = std::make_shared<Bundle>();
|
||||
bundle->putString("appId", appId);
|
||||
app::start(manifest.appId, bundle);
|
||||
namespace {
|
||||
|
||||
struct Context {
|
||||
uint32_t appInstanceId;
|
||||
std::string targetAppId;
|
||||
// findAppManifestById() returns the old-model registry's AppManifest type - AppDetails
|
||||
// shows details for apps in that registry regardless of which system they run under.
|
||||
AppManifest targetManifest = { };
|
||||
uint32_t pendingUninstallDialogId = 0;
|
||||
};
|
||||
|
||||
|
||||
void onPressUninstall(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
std::vector<std::string> choices = { "Yes", "No" };
|
||||
ctx->pendingUninstallDialogId = alertdialog::start(
|
||||
ctx->appInstanceId,
|
||||
"Confirmation",
|
||||
std::format("Uninstall {}?", ctx->targetManifest.name),
|
||||
choices
|
||||
);
|
||||
}
|
||||
|
||||
class AppDetailsApp : public App {
|
||||
void onBackPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
|
||||
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
|
||||
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
|
||||
// deadlock against itself.
|
||||
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(ctx->appInstanceId, &closeEvent);
|
||||
}
|
||||
|
||||
std::shared_ptr<AppManifest> manifest;
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
static void onPressUninstall(lv_event_t* event) {
|
||||
auto* self = static_cast<AppDetailsApp*>(lv_event_get_user_data(event));
|
||||
std::vector<std::string> choices = {
|
||||
"Yes",
|
||||
"No"
|
||||
};
|
||||
alertdialog::start("Confirmation", std::format("Uninstall {}?", self->manifest->appName), choices);
|
||||
}
|
||||
auto title = std::format("{} details", ctx->targetManifest.name);
|
||||
auto* toolbar = lvgl_toolbar_create(parent, title.c_str());
|
||||
// The global toolbar nav callback only knows how to stop old-model apps.
|
||||
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
|
||||
|
||||
public:
|
||||
auto* wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_width(wrapper, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(wrapper, 1);
|
||||
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT);
|
||||
lvgl::obj_set_style_bg_invisible(wrapper);
|
||||
|
||||
void onCreate(AppContext& app) override {
|
||||
const auto parameters = app.getParameters();
|
||||
check(parameters != nullptr, "Parameters missing");
|
||||
auto app_id = parameters->getString("appId");
|
||||
manifest = findAppManifestById(app_id);
|
||||
assert(manifest != nullptr);
|
||||
}
|
||||
auto identifier = std::format("Identifier: {}", ctx->targetManifest.id);
|
||||
auto* identifier_label = lv_label_create(wrapper);
|
||||
lv_label_set_text(identifier_label, identifier.c_str());
|
||||
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override {
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto title = std::format("{} details", manifest->appName);
|
||||
lvgl_toolbar_create(parent, title.c_str());
|
||||
|
||||
auto* wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_width(wrapper, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(wrapper, 1);
|
||||
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT);
|
||||
lvgl::obj_set_style_bg_invisible(wrapper);
|
||||
|
||||
auto identifier = std::format("Identifier: {}", manifest->appId);
|
||||
auto* identifier_label = lv_label_create(wrapper);
|
||||
lv_label_set_text(identifier_label, identifier.c_str());
|
||||
|
||||
auto* location_label = lv_label_create(wrapper);
|
||||
std::string location;
|
||||
if (manifest->appLocation.isInternal()) {
|
||||
location = "internal";
|
||||
} else {
|
||||
if (!string::getPathParent(manifest->appLocation.getPath(), location)) {
|
||||
location = "external";
|
||||
}
|
||||
auto* location_label = lv_label_create(wrapper);
|
||||
std::string location;
|
||||
bool is_internal = ctx->targetManifest.location.type == APP_LOCATION_MEMORY;
|
||||
bool is_external = ctx->targetManifest.location.type == APP_LOCATION_PATH;
|
||||
if (is_internal) {
|
||||
location = "internal";
|
||||
} else if (is_external) {
|
||||
if (!string::getPathParent(static_cast<const char*>(ctx->targetManifest.location.location), location)) {
|
||||
location = "external";
|
||||
}
|
||||
std::string location_label_text = std::format("Location: {}", location);
|
||||
lv_label_set_text(location_label, location_label_text.c_str());
|
||||
} else {
|
||||
LOG_E(TAG, "Unknown app location type %d", ctx->targetManifest.location.type);
|
||||
return;
|
||||
}
|
||||
std::string location_label_text = std::format("Location: {}", location);
|
||||
lv_label_set_text(location_label, location_label_text.c_str());
|
||||
|
||||
if (manifest->appLocation.isExternal()) {
|
||||
auto* uninstall_button = lv_button_create(wrapper);
|
||||
lv_obj_set_width(uninstall_button, LV_PCT(100));
|
||||
lv_obj_add_event_cb(uninstall_button, onPressUninstall, LV_EVENT_SHORT_CLICKED, this);
|
||||
auto* uninstall_label = lv_label_create(uninstall_button);
|
||||
lv_obj_align(uninstall_label, LV_ALIGN_CENTER, 0, 0);
|
||||
lv_label_set_text(uninstall_label, "Uninstall");
|
||||
if (is_external) {
|
||||
auto* uninstall_button = lv_button_create(wrapper);
|
||||
lv_obj_set_width(uninstall_button, LV_PCT(100));
|
||||
lv_obj_add_event_cb(uninstall_button, onPressUninstall, LV_EVENT_SHORT_CLICKED, ctx);
|
||||
auto* uninstall_label = lv_label_create(uninstall_button);
|
||||
lv_obj_align(uninstall_label, LV_ALIGN_CENTER, 0, 0);
|
||||
lv_label_set_text(uninstall_label, "Uninstall");
|
||||
}
|
||||
}
|
||||
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
|
||||
Context ctx {};
|
||||
ctx.appInstanceId = appInstanceId;
|
||||
ctx.targetAppId = (argc > 0) ? argv[0] : std::string();
|
||||
ctx.targetManifest = *app_manager_find_manifest(ctx.targetAppId.c_str());
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
|
||||
bool shouldClose = false;
|
||||
while (!shouldClose) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
switch (event.type) {
|
||||
case APP_EVENT_CLOSE:
|
||||
app_manager_finish(appInstanceId);
|
||||
shouldClose = true;
|
||||
break;
|
||||
case APP_EVENT_RESULT:
|
||||
if (event.result.launch_id == ctx.pendingUninstallDialogId) {
|
||||
if (event.result.result == 0) { // 0 = Yes
|
||||
app_uninstall(ctx.targetManifest.id);
|
||||
app_manager_finish(appInstanceId);
|
||||
shouldClose = true;
|
||||
}
|
||||
app_manager_stop(event.result.launch_id);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void onResult(AppContext& appContext, LaunchId launchId, Result result, std::unique_ptr<Bundle> bundle) override {
|
||||
if (result != Result::Ok || bundle == nullptr) {
|
||||
return;
|
||||
}
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
if (alertdialog::getResultIndex(*bundle) != 0) { // 0 = Yes
|
||||
return;
|
||||
}
|
||||
|
||||
uninstall(manifest->appId);
|
||||
|
||||
// Stop app
|
||||
stop();
|
||||
}
|
||||
};
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "AppDetails",
|
||||
.appName = "App Details",
|
||||
.appCategory = Category::System,
|
||||
.appFlags = AppManifest::Flags::Hidden,
|
||||
.createApp = create<AppDetailsApp>
|
||||
};
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void start(const std::string& appId) {
|
||||
const char* argv[] = { appId.c_str() };
|
||||
uint32_t instanceId = 0;
|
||||
app_manager_start_with_parameters(manifest.id, 1, argv, &instanceId);
|
||||
}
|
||||
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "AppDetails",
|
||||
.name = "App Details",
|
||||
.category = APP_CATEGORY_SYSTEM,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
|
||||
.flags = APP_MANIFEST_FLAG_HIDDEN,
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
#include <Tactility/Paths.h>
|
||||
#include <Tactility/DeprecatedPaths.h>
|
||||
#include <Tactility/Mutex.h>
|
||||
#include <Tactility/app/apphub/AppHub.h>
|
||||
#include <Tactility/app/apphub/AppHubEntry.h>
|
||||
#include <Tactility/app/apphubdetails/AppHubDetailsApp.h>
|
||||
#include <Tactility/file/File.h>
|
||||
#include <Tactility/lvgl/Toolbar.h>
|
||||
#include <Tactility/network/Http.h>
|
||||
#include <Tactility/service/loader/Loader.h>
|
||||
#include <Tactility/service/wifi/Wifi.h>
|
||||
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <tactility/log.h>
|
||||
|
||||
#include <lvgl/icons/shared.h>
|
||||
#include <lvgl/lvgl.h>
|
||||
#include <lvgl/widgets/spinner.h>
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <format>
|
||||
@@ -21,166 +26,189 @@ namespace tt::app::apphub {
|
||||
|
||||
constexpr auto* TAG = "AppHub";
|
||||
|
||||
extern const AppManifest manifest;
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
class AppHubApp final : public App {
|
||||
namespace {
|
||||
|
||||
struct Context {
|
||||
uint32_t appInstanceId;
|
||||
|
||||
lv_obj_t* contentWrapper = nullptr;
|
||||
lv_obj_t* refreshButton = nullptr;
|
||||
std::string cachedAppsJsonFile = std::format("{}/app_hub.json", getTempPath());
|
||||
std::unique_ptr<Thread> thread;
|
||||
std::vector<AppHubEntry> entries;
|
||||
Mutex mutex;
|
||||
|
||||
static std::shared_ptr<AppHubApp> findAppInstance() {
|
||||
auto app_context = getCurrentAppContext();
|
||||
if (app_context->getManifest().appId != manifest.appId) {
|
||||
return nullptr;
|
||||
}
|
||||
return std::static_pointer_cast<AppHubApp>(app_context->getApp());
|
||||
}
|
||||
|
||||
static void onAppPressed(lv_event_t* e) {
|
||||
const auto* self = static_cast<AppHubApp*>(lv_event_get_user_data(e));
|
||||
auto* widget = lv_event_get_target_obj(e);
|
||||
const auto* user_data = lv_obj_get_user_data(widget);
|
||||
const intptr_t index = reinterpret_cast<intptr_t>(user_data);
|
||||
self->mutex.lock();
|
||||
if (index < self->entries.size()) {
|
||||
apphubdetails::start(self->entries[index]);
|
||||
}
|
||||
self->mutex.unlock();
|
||||
}
|
||||
|
||||
static void onRefreshPressed(lv_event_t* e) {
|
||||
auto* self = static_cast<AppHubApp*>(lv_event_get_user_data(e));
|
||||
self->refresh();
|
||||
}
|
||||
|
||||
void onRefreshSuccess() {
|
||||
LOG_I(TAG, "Request success");
|
||||
lvgl_lock();
|
||||
showApps();
|
||||
lvgl_unlock();
|
||||
}
|
||||
|
||||
void onRefreshError(const char* error) {
|
||||
LOG_E(TAG, "Request failed: %s", error);
|
||||
lvgl_lock();
|
||||
showRefreshFailedError("Cannot reach server");
|
||||
lvgl_unlock();
|
||||
}
|
||||
|
||||
static void createAppWidget(const std::shared_ptr<AppManifest>& manifest, lv_obj_t* list) {
|
||||
lv_obj_t* btn = lv_list_add_button(list, nullptr, manifest->appName.c_str());
|
||||
lv_obj_add_event_cb(btn, &onAppPressed, LV_EVENT_SHORT_CLICKED, manifest.get());
|
||||
}
|
||||
|
||||
void showRefreshFailedError(const char* message) {
|
||||
lv_obj_clean(contentWrapper);
|
||||
|
||||
auto* label = lv_label_create(contentWrapper);
|
||||
lv_label_set_text(label, message);
|
||||
lv_obj_align(label, LV_ALIGN_CENTER, 0, 0);
|
||||
|
||||
lv_obj_remove_flag(refreshButton, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
|
||||
void showNoInternet() {
|
||||
showRefreshFailedError("No Internet Connection");
|
||||
}
|
||||
|
||||
void showTimeNotSynced() {
|
||||
showRefreshFailedError("Time is not synced yet.\nIt's required to establish a secure connection.");
|
||||
}
|
||||
|
||||
void showApps() {
|
||||
lv_obj_clean(contentWrapper);
|
||||
mutex.lock();
|
||||
if (parseJson(cachedAppsJsonFile, entries)) {
|
||||
std::ranges::sort(entries, [](auto left, auto right) {
|
||||
return left.appName < right.appName;
|
||||
});
|
||||
|
||||
auto* list = lv_list_create(contentWrapper);
|
||||
lv_obj_set_style_pad_all(list, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_size(list, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
for (int i = 0; i < entries.size(); i++) {
|
||||
auto& entry = entries[i];
|
||||
LOG_I(TAG, "Adding %s", entry.appName.c_str());
|
||||
const char* icon = findAppManifestById(entry.appId) != nullptr ? LV_SYMBOL_OK : nullptr;
|
||||
auto* entry_button = lv_list_add_button(list, icon, entry.appName.c_str());
|
||||
auto int_as_voidptr = reinterpret_cast<void*>(i);
|
||||
lv_obj_set_user_data(entry_button, int_as_voidptr);
|
||||
lv_obj_add_event_cb(entry_button, onAppPressed, LV_EVENT_SHORT_CLICKED, this);
|
||||
}
|
||||
} else {
|
||||
showRefreshFailedError("Failed to load content");
|
||||
}
|
||||
mutex.unlock();
|
||||
}
|
||||
|
||||
void refresh() {
|
||||
lv_obj_clean(contentWrapper);
|
||||
auto* spinner = lvgl_spinner_create(contentWrapper);
|
||||
lv_obj_align(spinner, LV_ALIGN_CENTER, 0, 0);
|
||||
|
||||
lv_obj_add_flag(refreshButton, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
if (service::wifi::getRadioState() != service::wifi::RadioState::ConnectionActive) {
|
||||
showNoInternet();
|
||||
return;
|
||||
}
|
||||
|
||||
if (file::isFile(cachedAppsJsonFile)) {
|
||||
showApps();
|
||||
}
|
||||
|
||||
network::http::download(
|
||||
getAppsJsonUrl(),
|
||||
CERTIFICATE_PATH,
|
||||
cachedAppsJsonFile,
|
||||
[] {
|
||||
auto app = findAppInstance();
|
||||
if (app != nullptr) {
|
||||
app->onRefreshSuccess();
|
||||
}
|
||||
},
|
||||
[](const char* error) {
|
||||
auto app = findAppInstance();
|
||||
if (app != nullptr) {
|
||||
app->onRefreshError(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public:
|
||||
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override {
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* toolbar = lvgl::toolbar_create(parent, app);
|
||||
refreshButton = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_REFRESH, onRefreshPressed, this);
|
||||
lv_obj_add_flag(refreshButton, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
contentWrapper = lv_obj_create(parent);
|
||||
lv_obj_set_width(contentWrapper, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(contentWrapper, 1);
|
||||
lv_obj_set_style_pad_all(contentWrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_ver(contentWrapper, 0, LV_STATE_DEFAULT);
|
||||
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "AppHub",
|
||||
.appName = "App Hub",
|
||||
.appIcon = LVGL_ICON_SHARED_HUB,
|
||||
.appCategory = Category::System,
|
||||
.createApp = create<AppHubApp>,
|
||||
|
||||
void showApps(Context* ctx);
|
||||
void refresh(Context* ctx);
|
||||
|
||||
void onBackPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
|
||||
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
|
||||
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
|
||||
// deadlock against itself.
|
||||
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(ctx->appInstanceId, &closeEvent);
|
||||
}
|
||||
|
||||
void onAppPressed(lv_event_t* e) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
|
||||
auto* widget = lv_event_get_target_obj(e);
|
||||
const auto* user_data = lv_obj_get_user_data(widget);
|
||||
const intptr_t index = reinterpret_cast<intptr_t>(user_data);
|
||||
ctx->mutex.lock();
|
||||
if (index < ctx->entries.size()) {
|
||||
apphubdetails::start(ctx->entries[index]);
|
||||
}
|
||||
ctx->mutex.unlock();
|
||||
}
|
||||
|
||||
void onRefreshPressed(lv_event_t* e) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
|
||||
refresh(ctx);
|
||||
}
|
||||
|
||||
void showRefreshFailedError(Context* ctx, const char* message) {
|
||||
lv_obj_clean(ctx->contentWrapper);
|
||||
|
||||
auto* label = lv_label_create(ctx->contentWrapper);
|
||||
lv_label_set_text(label, message);
|
||||
lv_obj_align(label, LV_ALIGN_CENTER, 0, 0);
|
||||
|
||||
lv_obj_remove_flag(ctx->refreshButton, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
|
||||
void showNoInternet(Context* ctx) {
|
||||
showRefreshFailedError(ctx, "No Internet Connection");
|
||||
}
|
||||
|
||||
void showApps(Context* ctx) {
|
||||
lv_obj_clean(ctx->contentWrapper);
|
||||
ctx->mutex.lock();
|
||||
if (parseJson(ctx->cachedAppsJsonFile, ctx->entries)) {
|
||||
std::ranges::sort(ctx->entries, [](auto left, auto right) {
|
||||
return left.appName < right.appName;
|
||||
});
|
||||
|
||||
auto* list = lv_list_create(ctx->contentWrapper);
|
||||
lv_obj_set_style_pad_all(list, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_size(list, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
for (int i = 0; i < ctx->entries.size(); i++) {
|
||||
auto& entry = ctx->entries[i];
|
||||
LOG_I(TAG, "Adding %s", entry.appName.c_str());
|
||||
const char* icon = app_manager_find_manifest(entry.appId.c_str()) != nullptr ? LV_SYMBOL_OK : nullptr;
|
||||
auto* entry_button = lv_list_add_button(list, icon, entry.appName.c_str());
|
||||
auto int_as_voidptr = reinterpret_cast<void*>(i);
|
||||
lv_obj_set_user_data(entry_button, int_as_voidptr);
|
||||
lv_obj_add_event_cb(entry_button, onAppPressed, LV_EVENT_SHORT_CLICKED, ctx);
|
||||
}
|
||||
} else {
|
||||
showRefreshFailedError(ctx, "Failed to load content");
|
||||
}
|
||||
ctx->mutex.unlock();
|
||||
}
|
||||
|
||||
void refresh(Context* ctx) {
|
||||
lv_obj_clean(ctx->contentWrapper);
|
||||
auto* spinner = lvgl_spinner_create(ctx->contentWrapper);
|
||||
lv_obj_align(spinner, LV_ALIGN_CENTER, 0, 0);
|
||||
|
||||
lv_obj_add_flag(ctx->refreshButton, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
if (service::wifi::getRadioState() != service::wifi::RadioState::ConnectionActive) {
|
||||
showNoInternet(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
if (file::isFile(ctx->cachedAppsJsonFile)) {
|
||||
showApps(ctx);
|
||||
}
|
||||
|
||||
// These callbacks run on a background network thread and reach back into this app's
|
||||
// widgets via the captured ctx pointer - same convention as AppHubDetailsApp.cpp's
|
||||
// download callback for the sibling "install/update" flow.
|
||||
network::http::download(
|
||||
getAppsJsonUrl(),
|
||||
CERTIFICATE_PATH,
|
||||
ctx->cachedAppsJsonFile,
|
||||
[ctx] {
|
||||
LOG_I(TAG, "Request success");
|
||||
lvgl_lock();
|
||||
showApps(ctx);
|
||||
lvgl_unlock();
|
||||
},
|
||||
[ctx](const char* error) {
|
||||
LOG_E(TAG, "Request failed: %s", error);
|
||||
lvgl_lock();
|
||||
showRefreshFailedError(ctx, "Cannot reach server");
|
||||
lvgl_unlock();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* toolbar = lvgl_toolbar_create(parent, "App Hub");
|
||||
// The global toolbar nav callback only knows how to stop old-model apps.
|
||||
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
|
||||
ctx->refreshButton = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_REFRESH, onRefreshPressed, ctx);
|
||||
lv_obj_add_flag(ctx->refreshButton, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
ctx->contentWrapper = lv_obj_create(parent);
|
||||
lv_obj_set_width(ctx->contentWrapper, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(ctx->contentWrapper, 1);
|
||||
lv_obj_set_style_pad_all(ctx->contentWrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_ver(ctx->contentWrapper, 0, LV_STATE_DEFAULT);
|
||||
|
||||
refresh(ctx);
|
||||
}
|
||||
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
Context ctx;
|
||||
ctx.appInstanceId = appInstanceId;
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
|
||||
bool shouldClose = false;
|
||||
while (!shouldClose) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
switch (event.type) {
|
||||
case APP_EVENT_CLOSE:
|
||||
app_manager_finish(appInstanceId);
|
||||
shouldClose = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "AppHub",
|
||||
.name = "App Hub",
|
||||
.category = APP_CATEGORY_SYSTEM,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -1,255 +1,317 @@
|
||||
#include <Tactility/Paths.h>
|
||||
#include "../../../../Modules/app-module/private/app/private/app_ledger.h"
|
||||
#include "app/metadata.h"
|
||||
|
||||
|
||||
#include <Tactility/DeprecatedPaths.h>
|
||||
#include <Tactility/StringUtils.h>
|
||||
#include <Tactility/app/AppRegistration.h>
|
||||
#include <Tactility/app/alertdialog/AlertDialog.h>
|
||||
#include <Tactility/app/apphub/AppHub.h>
|
||||
#include <Tactility/app/apphub/AppHubEntry.h>
|
||||
#include <Tactility/file/File.h>
|
||||
#include <Tactility/network/Http.h>
|
||||
#include <Tactility/service/loader/Loader.h>
|
||||
|
||||
#include <app/event.h>
|
||||
#include <app/install.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <lvgl/lvgl.h>
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
|
||||
#include <tactility/log.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdlib>
|
||||
#include <format>
|
||||
|
||||
namespace tt::app::apphubdetails {
|
||||
|
||||
constexpr auto* TAG = "AppHubDetails";
|
||||
|
||||
extern const AppManifest manifest;
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
static std::shared_ptr<Bundle> toBundle(const apphub::AppHubEntry& entry) {
|
||||
auto bundle = std::make_shared<Bundle>();
|
||||
bundle->putString("appId", entry.appId);
|
||||
bundle->putString("appVersionName", entry.appVersionName);
|
||||
bundle->putInt32("appVersionCode", entry.appVersionCode);
|
||||
bundle->putString("appName", entry.appName);
|
||||
bundle->putString("appDescription", entry.appDescription);
|
||||
bundle->putString("targetSdk", entry.targetSdk);
|
||||
bundle->putString("file", entry.file);
|
||||
bundle->putString("targetPlatforms", string::join(entry.targetPlatforms, ","));
|
||||
return bundle;
|
||||
}
|
||||
namespace {
|
||||
|
||||
static bool fromBundle(const Bundle& bundle, apphub::AppHubEntry& entry) {
|
||||
std::string target_platforms_string;
|
||||
auto result = bundle.optString("appId", entry.appId) &&
|
||||
bundle.optString("appVersionName", entry.appVersionName) &&
|
||||
bundle.optInt32("appVersionCode", entry.appVersionCode) &&
|
||||
bundle.optString("appName", entry.appName) &&
|
||||
bundle.optString("appDescription", entry.appDescription) &&
|
||||
bundle.optString("targetSdk", entry.targetSdk) &&
|
||||
bundle.optString("file", entry.file) &&
|
||||
bundle.optString("targetPlatforms", target_platforms_string);
|
||||
entry.targetPlatforms = string::split(target_platforms_string, ",");
|
||||
return result;
|
||||
}
|
||||
|
||||
class AppHubDetailsApp final : public App {
|
||||
|
||||
static constexpr auto* CONFIRM_TEXT = "Confirm";
|
||||
static constexpr auto* CANCEL_TEXT = "Cancel";
|
||||
static constexpr auto CONFIRMATION_BUTTON_INDEX = 0;
|
||||
const std::vector<const char*> CONFIRM_CANCEL_LABELS = { CONFIRM_TEXT, CANCEL_TEXT };
|
||||
constexpr auto* CONFIRM_TEXT = "Confirm";
|
||||
constexpr auto* CANCEL_TEXT = "Cancel";
|
||||
constexpr int32_t CONFIRMATION_BUTTON_INDEX = 0;
|
||||
|
||||
struct Context {
|
||||
uint32_t appInstanceId;
|
||||
apphub::AppHubEntry entry;
|
||||
std::shared_ptr<AppManifest> entryManifest;
|
||||
|
||||
lv_obj_t* toolbar = nullptr;
|
||||
lv_obj_t* spinner = nullptr;
|
||||
lv_obj_t* updateButton = nullptr;
|
||||
lv_obj_t* updateLabel = nullptr;
|
||||
LaunchId installLaunchId = -1;
|
||||
LaunchId uninstallLaunchId = -1;
|
||||
LaunchId updateLaunchId = -1;
|
||||
|
||||
LaunchId showConfirmDialog(const char* action) {
|
||||
const auto message = std::format("{} {}?", action, entry.appName);
|
||||
return alertdialog::start(CONFIRM_TEXT, message, CONFIRM_CANCEL_LABELS);
|
||||
}
|
||||
|
||||
static void onInstallPressed(lv_event_t* e) {
|
||||
auto* self = static_cast<AppHubDetailsApp*>(lv_event_get_user_data(e));
|
||||
self->installLaunchId = self->showConfirmDialog("Install");
|
||||
}
|
||||
|
||||
static void onUninstallPressed(lv_event_t* e) {
|
||||
auto* self = static_cast<AppHubDetailsApp*>(lv_event_get_user_data(e));
|
||||
self->uninstallLaunchId = self->showConfirmDialog("Uninstall");
|
||||
}
|
||||
|
||||
static void onUpdatePressed(lv_event_t* e) {
|
||||
auto* self = static_cast<AppHubDetailsApp*>(lv_event_get_user_data(e));
|
||||
self->updateLaunchId = self->showConfirmDialog("Update");
|
||||
}
|
||||
|
||||
void uninstallApp() {
|
||||
LOG_I(TAG, "Uninstall");
|
||||
|
||||
lvgl_lock();
|
||||
lv_obj_remove_flag(spinner, LV_OBJ_FLAG_HIDDEN);
|
||||
lvgl_unlock();
|
||||
|
||||
uninstall(entry.appId);
|
||||
|
||||
lvgl_lock();
|
||||
updateViews();
|
||||
lvgl_unlock();
|
||||
}
|
||||
|
||||
void doInstall() {
|
||||
auto url = apphub::getDownloadUrl(entry.file);
|
||||
auto file_name = file::getLastPathSegment(entry.file);
|
||||
auto temp_file_path = std::format("{}/{}", getTempPath(), file_name);
|
||||
network::http::download(
|
||||
url,
|
||||
apphub::CERTIFICATE_PATH,
|
||||
temp_file_path,
|
||||
[this, temp_file_path] {
|
||||
install(temp_file_path);
|
||||
|
||||
if (!file::deleteFile(temp_file_path)) {
|
||||
LOG_W(TAG, "Failed to remove %s", temp_file_path.c_str());
|
||||
} else {
|
||||
LOG_I(TAG, "Deleted temporary file %s", temp_file_path.c_str());
|
||||
}
|
||||
|
||||
lvgl_lock();
|
||||
updateViews();
|
||||
lvgl_unlock();
|
||||
},
|
||||
[temp_file_path](const char* errorMessage) {
|
||||
LOG_E(TAG, "Download failed: %s", errorMessage);
|
||||
alertdialog::start("Error", "Failed to install app");
|
||||
|
||||
if (file::isFile(temp_file_path) && !file::deleteFile(temp_file_path.c_str())) {
|
||||
LOG_W(TAG, "Failed to remove %s", temp_file_path.c_str());
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
void installApp() {
|
||||
LOG_I(TAG, "Install");
|
||||
|
||||
lvgl_lock();
|
||||
lv_obj_remove_flag(spinner, LV_OBJ_FLAG_HIDDEN);
|
||||
lvgl_unlock();
|
||||
|
||||
doInstall();
|
||||
}
|
||||
|
||||
void updateApp() {
|
||||
LOG_I(TAG, "Update");
|
||||
|
||||
lvgl_lock();
|
||||
lv_obj_remove_flag(spinner, LV_OBJ_FLAG_HIDDEN);
|
||||
lvgl_unlock();
|
||||
|
||||
LOG_I(TAG, "Removing previous version");
|
||||
uninstall(entry.appId);
|
||||
LOG_I(TAG, "Installing new version");
|
||||
doInstall();
|
||||
}
|
||||
|
||||
void updateViews() {
|
||||
lvgl_toolbar_clear_actions(toolbar);
|
||||
const auto manifest = findAppManifestById(entry.appId);
|
||||
spinner = lvgl_toolbar_add_spinner_action(toolbar);
|
||||
lv_obj_add_flag(spinner, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_add_flag(updateLabel, LV_OBJ_FLAG_HIDDEN);
|
||||
if (manifest != nullptr) {
|
||||
if (manifest->appVersionCode < entry.appVersionCode) {
|
||||
updateButton = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_DOWNLOAD, onUpdatePressed, this);
|
||||
lv_obj_remove_flag(updateLabel, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_TRASH, onUninstallPressed, this);
|
||||
} else {
|
||||
lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_DOWNLOAD, onInstallPressed, this);
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
|
||||
void onCreate(AppContext& appContext) override {
|
||||
auto parameters = appContext.getParameters();
|
||||
if (parameters == nullptr) {
|
||||
LOG_E(TAG, "No parameters");
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fromBundle(*parameters.get(), entry)) {
|
||||
LOG_E(TAG, "Invalid parameters");
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override {
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
toolbar = lvgl_toolbar_create(parent, entry.appName.c_str());
|
||||
auto* wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_width(wrapper, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(wrapper, 1);
|
||||
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
|
||||
updateLabel = lv_label_create(wrapper);
|
||||
lv_label_set_text(updateLabel, "Update available!");
|
||||
lv_obj_set_style_text_color(updateLabel, lv_color_make(0xff, 0xff, 00), LV_STATE_DEFAULT);
|
||||
|
||||
auto* description_label = lv_label_create(wrapper);
|
||||
lv_obj_set_width(description_label, LV_PCT(100));
|
||||
lv_label_set_long_mode(description_label, LV_LABEL_LONG_MODE_WRAP);
|
||||
if (!entry.appDescription.empty()) {
|
||||
std::string description = entry.appDescription;
|
||||
for (size_t pos = 0; (pos = description.find("\\n", pos)) != std::string::npos;) {
|
||||
description.replace(pos, 2, "\n");
|
||||
}
|
||||
lv_label_set_text(description_label, description.c_str());
|
||||
} else {
|
||||
lv_label_set_text(description_label, "This app has no description yet.");
|
||||
}
|
||||
|
||||
auto* version_label = lv_label_create(wrapper);
|
||||
lv_label_set_text_fmt(version_label, "Version %s", entry.appVersionName.c_str());
|
||||
|
||||
updateViews();
|
||||
}
|
||||
|
||||
void onResult(AppContext& appContext, LaunchId launchId, Result result, std::unique_ptr<Bundle> resultData) override {
|
||||
if (result != Result::Ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (alertdialog::getResultIndex(*resultData.get()) != CONFIRMATION_BUTTON_INDEX) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (launchId == installLaunchId) {
|
||||
installApp();
|
||||
} else if (launchId == uninstallLaunchId) {
|
||||
uninstallApp();
|
||||
} else if (launchId == updateLaunchId) {
|
||||
updateApp();
|
||||
}
|
||||
}
|
||||
// Set from the LVGL task (button press), read from this app's own thread (event loop) -
|
||||
// both directions cross threads, hence atomic.
|
||||
std::atomic<uint32_t> installDialogId = 0;
|
||||
std::atomic<uint32_t> uninstallDialogId = 0;
|
||||
std::atomic<uint32_t> updateDialogId = 0;
|
||||
};
|
||||
|
||||
void start(const apphub::AppHubEntry& entry) {
|
||||
const auto bundle = toBundle(entry);
|
||||
app::start(manifest.appId, bundle);
|
||||
|
||||
void updateViews(Context* ctx);
|
||||
|
||||
uint32_t showConfirmDialog(Context* ctx, const char* action) {
|
||||
const auto message = std::format("{} {}?", action, ctx->entry.appName);
|
||||
return alertdialog::start(ctx->appInstanceId, CONFIRM_TEXT, message, std::vector<std::string> { CONFIRM_TEXT, CANCEL_TEXT });
|
||||
}
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "AppHubDetails",
|
||||
.appName = "App Details",
|
||||
.appCategory = Category::System,
|
||||
.appFlags = AppManifest::Flags::Hidden,
|
||||
.createApp = create<AppHubDetailsApp>,
|
||||
void onBackPressed(lv_event_t* e) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
|
||||
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
|
||||
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
|
||||
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
|
||||
// deadlock against itself.
|
||||
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(ctx->appInstanceId, &closeEvent);
|
||||
}
|
||||
|
||||
void onInstallPressed(lv_event_t* e) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
|
||||
ctx->installDialogId = showConfirmDialog(ctx, "Install");
|
||||
}
|
||||
|
||||
void onUninstallPressed(lv_event_t* e) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
|
||||
ctx->uninstallDialogId = showConfirmDialog(ctx, "Uninstall");
|
||||
}
|
||||
|
||||
void onUpdatePressed(lv_event_t* e) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
|
||||
ctx->updateDialogId = showConfirmDialog(ctx, "Update");
|
||||
}
|
||||
|
||||
void uninstallApp(Context* ctx) {
|
||||
LOG_I(TAG, "Uninstall");
|
||||
|
||||
lvgl_lock();
|
||||
lv_obj_remove_flag(ctx->spinner, LV_OBJ_FLAG_HIDDEN);
|
||||
lvgl_unlock();
|
||||
|
||||
app_uninstall(ctx->entry.appId.c_str());
|
||||
|
||||
lvgl_lock();
|
||||
updateViews(ctx);
|
||||
lvgl_unlock();
|
||||
}
|
||||
|
||||
void doInstall(Context* ctx) {
|
||||
auto url = apphub::getDownloadUrl(ctx->entry.file);
|
||||
auto file_name = file::getLastPathSegment(ctx->entry.file);
|
||||
auto temp_file_path = std::format("{}/{}", getTempPath(), file_name);
|
||||
network::http::download(
|
||||
url,
|
||||
apphub::CERTIFICATE_PATH,
|
||||
temp_file_path,
|
||||
[ctx, temp_file_path] {
|
||||
app_install(temp_file_path.c_str());
|
||||
|
||||
if (!file::deleteFile(temp_file_path)) {
|
||||
LOG_W(TAG, "Failed to remove %s", temp_file_path.c_str());
|
||||
} else {
|
||||
LOG_I(TAG, "Deleted temporary file %s", temp_file_path.c_str());
|
||||
}
|
||||
|
||||
lvgl_lock();
|
||||
updateViews(ctx);
|
||||
lvgl_unlock();
|
||||
},
|
||||
[ctx, temp_file_path](const char* errorMessage) {
|
||||
LOG_E(TAG, "Download failed: %s", errorMessage);
|
||||
alertdialog::start(ctx->appInstanceId, "Error", "Failed to install app");
|
||||
|
||||
if (file::isFile(temp_file_path) && !file::deleteFile(temp_file_path.c_str())) {
|
||||
LOG_W(TAG, "Failed to remove %s", temp_file_path.c_str());
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
void installApp(Context* ctx) {
|
||||
LOG_I(TAG, "Install");
|
||||
|
||||
lvgl_lock();
|
||||
lv_obj_remove_flag(ctx->spinner, LV_OBJ_FLAG_HIDDEN);
|
||||
lvgl_unlock();
|
||||
|
||||
doInstall(ctx);
|
||||
}
|
||||
|
||||
void updateApp(Context* ctx) {
|
||||
LOG_I(TAG, "Update");
|
||||
|
||||
lvgl_lock();
|
||||
lv_obj_remove_flag(ctx->spinner, LV_OBJ_FLAG_HIDDEN);
|
||||
lvgl_unlock();
|
||||
|
||||
LOG_I(TAG, "Removing previous version");
|
||||
app_uninstall(ctx->entry.appId.c_str());
|
||||
LOG_I(TAG, "Installing new version");
|
||||
doInstall(ctx);
|
||||
}
|
||||
|
||||
void updateViews(Context* ctx) {
|
||||
lvgl_toolbar_clear_actions(ctx->toolbar);
|
||||
auto app_id = ctx->entry.appId.c_str();
|
||||
const auto manifest = app_manager_find_manifest(app_id);
|
||||
ctx->spinner = lvgl_toolbar_add_spinner_action(ctx->toolbar);
|
||||
lv_obj_add_flag(ctx->spinner, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_add_flag(ctx->updateLabel, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
char install_path[128];
|
||||
if (app_get_install_path(app_id, install_path, sizeof(install_path)) != ERROR_NONE) {
|
||||
LOG_E(TAG, "Install path not found for %s", app_id);
|
||||
return;
|
||||
}
|
||||
|
||||
std::string metadata_path = std::string(install_path) + "/manifest.properties";
|
||||
AppMetadata metadata;
|
||||
if (app_metadata_parse(metadata_path.c_str(), &metadata) != ERROR_NONE) {
|
||||
LOG_E(TAG, "Failed to parse metadata at %s", metadata_path.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
if (manifest != nullptr) {
|
||||
if (metadata.app_version_code < ctx->entry.appVersionCode) {
|
||||
ctx->updateButton = lvgl_toolbar_add_image_button_action(ctx->toolbar, LV_SYMBOL_DOWNLOAD, onUpdatePressed, ctx);
|
||||
lv_obj_remove_flag(ctx->updateLabel, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
lvgl_toolbar_add_image_button_action(ctx->toolbar, LV_SYMBOL_TRASH, onUninstallPressed, ctx);
|
||||
} else {
|
||||
lvgl_toolbar_add_image_button_action(ctx->toolbar, LV_SYMBOL_DOWNLOAD, onInstallPressed, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
ctx->toolbar = lvgl_toolbar_create(parent, ctx->entry.appName.c_str());
|
||||
// The global toolbar nav callback only knows how to stop old-model apps.
|
||||
lvgl_toolbar_set_nav_action(ctx->toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
|
||||
auto* wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_width(wrapper, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(wrapper, 1);
|
||||
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
|
||||
ctx->updateLabel = lv_label_create(wrapper);
|
||||
lv_label_set_text(ctx->updateLabel, "Update available!");
|
||||
lv_obj_set_style_text_color(ctx->updateLabel, lv_color_make(0xff, 0xff, 00), LV_STATE_DEFAULT);
|
||||
|
||||
auto* description_label = lv_label_create(wrapper);
|
||||
lv_obj_set_width(description_label, LV_PCT(100));
|
||||
lv_label_set_long_mode(description_label, LV_LABEL_LONG_MODE_WRAP);
|
||||
if (!ctx->entry.appDescription.empty()) {
|
||||
std::string description = ctx->entry.appDescription;
|
||||
for (size_t pos = 0; (pos = description.find("\\n", pos)) != std::string::npos;) {
|
||||
description.replace(pos, 2, "\n");
|
||||
}
|
||||
lv_label_set_text(description_label, description.c_str());
|
||||
} else {
|
||||
lv_label_set_text(description_label, "This app has no description yet.");
|
||||
}
|
||||
|
||||
auto* version_label = lv_label_create(wrapper);
|
||||
lv_label_set_text_fmt(version_label, "Version %s", ctx->entry.appVersionName.c_str());
|
||||
|
||||
updateViews(ctx);
|
||||
}
|
||||
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
// argv layout: [0]=appId, [1]=appVersionName, [2]=appVersionCode, [3]=appName,
|
||||
// [4]=appDescription, [5]=targetSdk, [6]=file, [7..argc)=targetPlatforms.
|
||||
|
||||
Context ctx {};
|
||||
ctx.appInstanceId = appInstanceId;
|
||||
if (argc >= 7) {
|
||||
ctx.entry.appId = argv[0];
|
||||
ctx.entry.appVersionName = argv[1];
|
||||
ctx.entry.appVersionCode = static_cast<int32_t>(strtol(argv[2], nullptr, 10));
|
||||
ctx.entry.appName = argv[3];
|
||||
ctx.entry.appDescription = argv[4];
|
||||
ctx.entry.targetSdk = argv[5];
|
||||
ctx.entry.file = argv[6];
|
||||
for (int i = 7; i < argc; i++) {
|
||||
ctx.entry.targetPlatforms.emplace_back(argv[i]);
|
||||
}
|
||||
}
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
|
||||
bool shouldClose = false;
|
||||
while (!shouldClose) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
switch (event.type) {
|
||||
case APP_EVENT_CLOSE:
|
||||
app_manager_finish(appInstanceId);
|
||||
shouldClose = true;
|
||||
break;
|
||||
case APP_EVENT_RESULT: {
|
||||
bool confirmed = event.result.result == CONFIRMATION_BUTTON_INDEX;
|
||||
if (event.result.launch_id == ctx.installDialogId && confirmed) {
|
||||
installApp(&ctx);
|
||||
} else if (event.result.launch_id == ctx.uninstallDialogId && confirmed) {
|
||||
uninstallApp(&ctx);
|
||||
} else if (event.result.launch_id == ctx.updateDialogId && confirmed) {
|
||||
updateApp(&ctx);
|
||||
}
|
||||
app_manager_stop(event.result.launch_id);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void start(const apphub::AppHubEntry& entry) {
|
||||
// Fire-and-forget (parent_instance_id 0): AppHub's own multi-app browsing list isn't
|
||||
// waiting on a result. targetPlatforms is variable-length, so it goes last in argv.
|
||||
std::string versionCode = std::to_string(entry.appVersionCode);
|
||||
std::vector<const char*> argv {
|
||||
entry.appId.c_str(),
|
||||
entry.appVersionName.c_str(),
|
||||
versionCode.c_str(),
|
||||
entry.appName.c_str(),
|
||||
entry.appDescription.c_str(),
|
||||
entry.targetSdk.c_str(),
|
||||
entry.file.c_str(),
|
||||
};
|
||||
for (const auto& platform: entry.targetPlatforms) {
|
||||
argv.push_back(platform.c_str());
|
||||
}
|
||||
uint32_t instanceId = 0;
|
||||
app_manager_start_for_result(manifest.id, /*parent_instance_id=*/0, static_cast<int>(argv.size()), argv.data(), &instanceId);
|
||||
}
|
||||
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "AppHubDetails",
|
||||
.name = "App Details",
|
||||
.category = APP_CATEGORY_SYSTEM,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
|
||||
.flags = APP_MANIFEST_FLAG_HIDDEN,
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -1,63 +1,116 @@
|
||||
#include <Tactility/app/AppRegistration.h>
|
||||
#include <Tactility/service/loader/Loader.h>
|
||||
#include <Tactility/lvgl/Toolbar.h>
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <lvgl.h>
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include <lvgl/icons/shared.h>
|
||||
#include <lvgl/fonts.h>
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
|
||||
namespace tt::app::applist {
|
||||
|
||||
class AppListApp final : public App {
|
||||
namespace {
|
||||
|
||||
static void onAppPressed(lv_event_t* e) {
|
||||
const auto* manifest = static_cast<const AppManifest*>(lv_event_get_user_data(e));
|
||||
start(manifest->appId);
|
||||
}
|
||||
uint32_t appListInstanceId = 0;
|
||||
|
||||
static void createAppWidget(const std::shared_ptr<AppManifest>& manifest, lv_obj_t* list) {
|
||||
const void* icon = !manifest->appIcon.empty() ? manifest->appIcon.c_str() : LVGL_ICON_SHARED_TOOLBAR;
|
||||
lv_obj_t* btn = lv_list_add_button(list, icon, manifest->appName.c_str());
|
||||
lv_obj_t* image = lv_obj_get_child(btn, 0);
|
||||
lv_obj_set_style_text_font(image, lvgl_get_shared_icon_font(), LV_PART_MAIN);
|
||||
lv_obj_add_event_cb(btn, &onAppPressed, LV_EVENT_SHORT_CLICKED, manifest.get());
|
||||
}
|
||||
void onAppPressed(lv_event_t* e) {
|
||||
// Fire-and-forget top-level navigation, same as Launcher's own app-launch buttons.
|
||||
const auto* manifest = static_cast<const ::AppManifest*>(lv_event_get_user_data(e));
|
||||
uint32_t instanceId = 0;
|
||||
app_manager_start(manifest->id, &instanceId);
|
||||
}
|
||||
|
||||
public:
|
||||
void onBackPressed(lv_event_t*) {
|
||||
// The global toolbar nav callback (ToolbarConfig.nav_action_callback, set once in
|
||||
// Tactility.cpp) only knows how to stop old-model apps, so this new-model app overrides
|
||||
// its own toolbar's nav action to close itself instead. Async, non-blocking - must NOT
|
||||
// call app_manager_stop() directly here: that bound-waits (thread_join) for this app's
|
||||
// own thread to finish, which needs the LVGL lock (window_manager_remove()) - but this
|
||||
// callback runs ON the LVGL task, which would deadlock against itself.
|
||||
AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(appListInstanceId, &event);
|
||||
}
|
||||
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override {
|
||||
auto* toolbar = lvgl::toolbar_create(parent, app);
|
||||
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||
void createAppWidget(const ::AppManifest* manifest, lv_obj_t* list) {
|
||||
// The new AppManifest has no per-app icon - use a shared generic one for every entry,
|
||||
// same fallback the old model used for apps that didn't provide one.
|
||||
lv_obj_t* btn = lv_list_add_button(list, LVGL_ICON_SHARED_TOOLBAR, manifest->name);
|
||||
lv_obj_t* image = lv_obj_get_child(btn, 0);
|
||||
lv_obj_set_style_text_font(image, lvgl_get_shared_icon_font(), LV_PART_MAIN);
|
||||
lv_obj_add_event_cb(btn, &onAppPressed, LV_EVENT_SHORT_CLICKED, const_cast<::AppManifest*>(manifest));
|
||||
}
|
||||
|
||||
lv_obj_t* list = lv_list_create(parent);
|
||||
lv_obj_set_width(list, LV_PCT(100));
|
||||
lv_obj_align_to(list, toolbar, LV_ALIGN_OUT_BOTTOM_MID, 0, 0);
|
||||
void collectManifest(const ::AppManifest* manifest, void* context) {
|
||||
auto* manifests = static_cast<std::vector<const ::AppManifest*>*>(context);
|
||||
manifests->push_back(manifest);
|
||||
}
|
||||
|
||||
auto toolbar_height = lv_obj_get_height(toolbar);
|
||||
auto parent_content_height = lv_obj_get_content_height(parent);
|
||||
lv_obj_set_height(list, parent_content_height - toolbar_height);
|
||||
void createWidgets(lv_obj_t* parent, void*) {
|
||||
auto* toolbar = lvgl_toolbar_create(parent, "Apps");
|
||||
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, nullptr);
|
||||
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||
|
||||
auto manifests = getAppManifests();
|
||||
std::ranges::sort(manifests, SortAppManifestByName);
|
||||
lv_obj_t* list = lv_list_create(parent);
|
||||
lv_obj_set_width(list, LV_PCT(100));
|
||||
lv_obj_align_to(list, toolbar, LV_ALIGN_OUT_BOTTOM_MID, 0, 0);
|
||||
|
||||
for (const auto& manifest: manifests) {
|
||||
bool is_valid_category = (manifest->appCategory == Category::User) || (manifest->appCategory == Category::System);
|
||||
bool is_visible = (manifest->appFlags & AppManifest::Flags::Hidden) == 0u;
|
||||
if (is_valid_category && is_visible) {
|
||||
createAppWidget(manifest, list);
|
||||
}
|
||||
auto toolbar_height = lv_obj_get_height(toolbar);
|
||||
auto parent_content_height = lv_obj_get_content_height(parent);
|
||||
lv_obj_set_height(list, parent_content_height - toolbar_height);
|
||||
|
||||
std::vector<const ::AppManifest*> manifests;
|
||||
app_manager_for_each_manifest(collectManifest, &manifests);
|
||||
std::ranges::sort(manifests, [](const ::AppManifest* a, const ::AppManifest* b) {
|
||||
return strcmp(a->name, b->name) < 0;
|
||||
});
|
||||
|
||||
for (const auto* manifest: manifests) {
|
||||
bool is_valid_category = (manifest->category == APP_CATEGORY_USER) || (manifest->category == APP_CATEGORY_SYSTEM);
|
||||
if (is_valid_category && (manifest->flags & APP_MANIFEST_FLAG_HIDDEN) == 0) {
|
||||
createAppWidget(manifest, list);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "AppList",
|
||||
.appName = "Apps",
|
||||
.appCategory = Category::System,
|
||||
.appFlags = AppManifest::Flags::Hidden,
|
||||
.createApp = create<AppListApp>,
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
appListInstanceId = appInstanceId;
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, nullptr);
|
||||
|
||||
while (true) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
if (event.type == APP_EVENT_CLOSE) {
|
||||
app_manager_finish(appInstanceId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "AppList",
|
||||
.name = "Apps",
|
||||
.category = APP_CATEGORY_SYSTEM,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
|
||||
.flags = APP_MANIFEST_FLAG_HIDDEN,
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -1,70 +1,130 @@
|
||||
#include <lvgl/icons/shared.h>
|
||||
#include <lvgl/fonts.h>
|
||||
|
||||
#include <Tactility/app/AppRegistration.h>
|
||||
#include <Tactility/app/appdetails/AppDetails.h>
|
||||
#include <Tactility/service/loader/Loader.h>
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
#include <lvgl.h>
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
namespace tt::app::appsettings {
|
||||
|
||||
class AppSettingsApp final : public App {
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
static void onAppPressed(lv_event_t* e) {
|
||||
const auto* manifest = static_cast<const AppManifest*>(lv_event_get_user_data(e));
|
||||
appdetails::start(manifest->appId);
|
||||
}
|
||||
namespace {
|
||||
|
||||
static void createAppWidget(const std::shared_ptr<AppManifest>& manifest, lv_obj_t* list) {
|
||||
const void* icon = !manifest->appIcon.empty() ? manifest->appIcon.c_str() : LVGL_ICON_SHARED_TOOLBAR;
|
||||
lv_obj_t* btn = lv_list_add_button(list, icon, manifest->appName.c_str());
|
||||
lv_obj_t* image = lv_obj_get_child(btn, 0);
|
||||
lv_obj_set_style_text_font(image, lvgl_get_shared_icon_font(), LV_PART_MAIN);
|
||||
lv_obj_add_event_cb(btn, &onAppPressed, LV_EVENT_SHORT_CLICKED, manifest.get());
|
||||
}
|
||||
// Set by appMain() right before window_manager_create(), read by onBackPressed().
|
||||
uint32_t appSettingsInstanceId = 0;
|
||||
|
||||
public:
|
||||
void onAppPressed(lv_event_t* e) {
|
||||
const auto* target_manifest = static_cast<const ::AppManifest*>(lv_event_get_user_data(e));
|
||||
appdetails::start(target_manifest->id);
|
||||
}
|
||||
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override {
|
||||
auto* toolbar = lvgl_toolbar_create(parent, "Installed Apps");
|
||||
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||
void onBackPressed(lv_event_t*) {
|
||||
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
|
||||
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
|
||||
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
|
||||
// deadlock against itself.
|
||||
AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(appSettingsInstanceId, &event);
|
||||
}
|
||||
|
||||
lv_obj_t* list = lv_list_create(parent);
|
||||
lv_obj_set_width(list, LV_PCT(100));
|
||||
lv_obj_align_to(list, toolbar, LV_ALIGN_OUT_BOTTOM_MID, 0, 0);
|
||||
void createAppWidget(const ::AppManifest* target_manifest, lv_obj_t* list) {
|
||||
// The new AppManifest has no per-app icon - use a shared generic one for every entry, same
|
||||
// fallback AppList.cpp uses.
|
||||
lv_obj_t* btn = lv_list_add_button(list, LVGL_ICON_SHARED_TOOLBAR, target_manifest->name);
|
||||
lv_obj_t* image = lv_obj_get_child(btn, 0);
|
||||
lv_obj_set_style_text_font(image, lvgl_get_shared_icon_font(), LV_PART_MAIN);
|
||||
lv_obj_add_event_cb(btn, &onAppPressed, LV_EVENT_SHORT_CLICKED, const_cast<::AppManifest*>(target_manifest));
|
||||
}
|
||||
|
||||
auto toolbar_height = lv_obj_get_height(toolbar);
|
||||
auto parent_content_height = lv_obj_get_content_height(parent);
|
||||
lv_obj_set_height(list, parent_content_height - toolbar_height);
|
||||
void collectManifest(const ::AppManifest* manifest, void* context) {
|
||||
auto* manifests = static_cast<std::vector<const ::AppManifest*>*>(context);
|
||||
manifests->push_back(manifest);
|
||||
}
|
||||
|
||||
auto manifests = getAppManifests();
|
||||
std::ranges::sort(manifests, SortAppManifestByName);
|
||||
void createWidgets(lv_obj_t* parent, void*) {
|
||||
auto* toolbar = lvgl_toolbar_create(parent, "Installed Apps");
|
||||
// The global toolbar nav callback only knows how to stop old-model apps.
|
||||
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, nullptr);
|
||||
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||
|
||||
size_t app_count = 0;
|
||||
for (const auto& manifest: manifests) {
|
||||
if (manifest->appLocation.isExternal()) {
|
||||
app_count++;
|
||||
createAppWidget(manifest, list);
|
||||
}
|
||||
}
|
||||
lv_obj_t* list = lv_list_create(parent);
|
||||
lv_obj_set_width(list, LV_PCT(100));
|
||||
lv_obj_align_to(list, toolbar, LV_ALIGN_OUT_BOTTOM_MID, 0, 0);
|
||||
|
||||
if (app_count == 0) {
|
||||
auto* no_apps_label = lv_label_create(parent);
|
||||
lv_label_set_text(no_apps_label, "No apps installed");
|
||||
lv_obj_align(no_apps_label, LV_ALIGN_CENTER, 0, 0);
|
||||
auto toolbar_height = lv_obj_get_height(toolbar);
|
||||
auto parent_content_height = lv_obj_get_content_height(parent);
|
||||
lv_obj_set_height(list, parent_content_height - toolbar_height);
|
||||
|
||||
std::vector<const ::AppManifest*> manifests;
|
||||
app_manager_for_each_manifest(collectManifest, &manifests);
|
||||
std::ranges::sort(manifests, [](const ::AppManifest* a, const ::AppManifest* b) {
|
||||
return strcmp(a->name, b->name) < 0;
|
||||
});
|
||||
|
||||
size_t app_count = 0;
|
||||
for (const auto* target_manifest: manifests) {
|
||||
if (target_manifest->location.type == APP_LOCATION_PATH) {
|
||||
app_count++;
|
||||
createAppWidget(target_manifest, list);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "AppSettings",
|
||||
.appName = "Apps",
|
||||
.appIcon = LVGL_ICON_SHARED_APPS,
|
||||
.appCategory = Category::Settings,
|
||||
.createApp = create<AppSettingsApp>,
|
||||
if (app_count == 0) {
|
||||
auto* no_apps_label = lv_label_create(parent);
|
||||
lv_label_set_text(no_apps_label, "No apps installed");
|
||||
lv_obj_align(no_apps_label, LV_ALIGN_CENTER, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
appSettingsInstanceId = appInstanceId;
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, nullptr);
|
||||
|
||||
bool shouldClose = false;
|
||||
while (!shouldClose) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
switch (event.type) {
|
||||
case APP_EVENT_CLOSE:
|
||||
app_manager_finish(appInstanceId);
|
||||
shouldClose = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "AppSettings",
|
||||
.name = "Apps",
|
||||
.category = APP_CATEGORY_SETTINGS,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -1,130 +1,186 @@
|
||||
#ifdef ESP_PLATFORM
|
||||
|
||||
#include <Tactility/Tactility.h>
|
||||
#include <Tactility/app/App.h>
|
||||
#include <Tactility/lvgl/Toolbar.h>
|
||||
#include <Tactility/service/webserver/WebServerService.h>
|
||||
#include <Tactility/settings/WebServerSettings.h>
|
||||
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <lvgl.h>
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
#include <tactility/log.h>
|
||||
|
||||
namespace tt::app::apwebserver {
|
||||
|
||||
constexpr auto* TAG = "ApWebServerApp";
|
||||
|
||||
class ApWebServerApp final : public App {
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
namespace {
|
||||
|
||||
struct Context {
|
||||
uint32_t appInstanceId;
|
||||
lv_obj_t* labelSsidValue = nullptr;
|
||||
lv_obj_t* labelPasswordValue = nullptr;
|
||||
lv_obj_t* labelIpValue = nullptr;
|
||||
|
||||
|
||||
bool webServerEnabledChanged = false;
|
||||
settings::webserver::WebServerSettings wsSettings;
|
||||
|
||||
public:
|
||||
void onCreate(AppContext& app) override {
|
||||
wsSettings = settings::webserver::loadOrGetDefault();
|
||||
}
|
||||
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override {
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
lvgl::toolbar_create(parent, app);
|
||||
|
||||
lv_obj_t* wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_width(wrapper, LV_PCT(100));
|
||||
lv_obj_set_style_pad_all(wrapper, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_row(wrapper, 4, LV_PART_MAIN);
|
||||
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
|
||||
lv_obj_t* labelSsid = lv_label_create(wrapper);
|
||||
lv_label_set_text(labelSsid, "SSID:");
|
||||
lv_obj_set_style_text_color(labelSsid, lv_palette_main(LV_PALETTE_GREY), LV_PART_MAIN);
|
||||
|
||||
labelSsidValue = lv_label_create(wrapper);
|
||||
lv_obj_set_style_text_align(labelSsidValue, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN);
|
||||
lv_obj_set_width(labelSsidValue, LV_PCT(100));
|
||||
lv_label_set_long_mode(labelSsidValue, LV_LABEL_LONG_SCROLL);
|
||||
lv_obj_set_style_margin_hor(labelSsidValue, 2, LV_PART_MAIN);
|
||||
|
||||
lv_obj_t* labelPassword = lv_label_create(wrapper);
|
||||
lv_label_set_text(labelPassword, "Pass:");
|
||||
lv_obj_set_style_text_color(labelPassword, lv_palette_main(LV_PALETTE_GREY), LV_PART_MAIN);
|
||||
|
||||
labelPasswordValue = lv_label_create(wrapper);
|
||||
lv_obj_set_style_text_align(labelPasswordValue, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN);
|
||||
lv_obj_set_width(labelPasswordValue, LV_PCT(100));
|
||||
lv_label_set_long_mode(labelPasswordValue, LV_LABEL_LONG_SCROLL);
|
||||
lv_obj_set_style_margin_hor(labelPasswordValue, 2, LV_PART_MAIN);
|
||||
|
||||
lv_obj_t* labelIp = lv_label_create(wrapper);
|
||||
lv_label_set_text(labelIp, "IP:");
|
||||
lv_obj_set_style_text_color(labelIp, lv_palette_main(LV_PALETTE_GREY), LV_PART_MAIN);
|
||||
|
||||
labelIpValue = lv_label_create(wrapper);
|
||||
lv_obj_set_style_text_align(labelIpValue, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN);
|
||||
lv_obj_set_width(labelIpValue, LV_PCT(100));
|
||||
lv_label_set_long_mode(labelIpValue, LV_LABEL_LONG_SCROLL);
|
||||
lv_obj_set_style_margin_hor(labelIpValue, 2, LV_PART_MAIN);
|
||||
|
||||
// Start AP Mode and WebServer
|
||||
settings::webserver::WebServerSettings apSettings = wsSettings;
|
||||
apSettings.wifiMode = settings::webserver::WiFiMode::AccessPoint;
|
||||
apSettings.webServerEnabled = true;
|
||||
|
||||
if (apSettings.apSsid.empty()) {
|
||||
apSettings.apSsid = settings::webserver::generateDefaultApSsid();
|
||||
}
|
||||
|
||||
// Generate password if it's an open network or if password is empty
|
||||
if (apSettings.apOpenNetwork || apSettings.apPassword.empty()) {
|
||||
apSettings.apPassword = settings::webserver::generateRandomCredential(12);
|
||||
apSettings.apOpenNetwork = false;
|
||||
}
|
||||
|
||||
lv_label_set_text(labelSsidValue, apSettings.apSsid.c_str());
|
||||
lv_label_set_text(labelPasswordValue, apSettings.apPassword.c_str());
|
||||
lv_label_set_text(labelIpValue, "192.168.4.1");
|
||||
|
||||
// Apply settings and start services
|
||||
getMainDispatcher().dispatch([apSettings] {
|
||||
if (!settings::webserver::save(apSettings)) {
|
||||
LOG_E(TAG, "Failed to save AP settings");
|
||||
return;
|
||||
}
|
||||
service::webserver::getPubsub()->publish(service::webserver::WebServerEvent::WebServerSettingsChanged);
|
||||
service::webserver::setWebServerEnabled(true);
|
||||
});
|
||||
webServerEnabledChanged = true;
|
||||
}
|
||||
|
||||
void onHide(AppContext& app) override {
|
||||
const auto copy = wsSettings;
|
||||
const bool webServerChanged = webServerEnabledChanged;
|
||||
|
||||
getMainDispatcher().dispatch([copy, webServerChanged] {
|
||||
if (!settings::webserver::save(copy)) {
|
||||
LOG_W(TAG, "Failed to persist WebServer settings; changes may be lost on reboot");
|
||||
}
|
||||
|
||||
service::webserver::getPubsub()->publish(service::webserver::WebServerEvent::WebServerSettingsChanged);
|
||||
|
||||
if (webServerChanged) {
|
||||
LOG_I(TAG, "WebServer %s", copy.webServerEnabled ? "enabling..." : "disabling...");
|
||||
service::webserver::setWebServerEnabled(copy.webServerEnabled);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "ApWebServer",
|
||||
.appName = "AP Web Server",
|
||||
.appCategory = Category::System,
|
||||
.appFlags = AppManifest::Flags::Hidden,
|
||||
.createApp = create<ApWebServerApp>
|
||||
|
||||
void onBackPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
|
||||
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
|
||||
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
|
||||
// deadlock against itself.
|
||||
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(ctx->appInstanceId, &closeEvent);
|
||||
}
|
||||
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
auto* toolbar = lvgl_toolbar_create(parent, "AP Web Server");
|
||||
// The global toolbar nav callback only knows how to stop old-model apps.
|
||||
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
|
||||
|
||||
lv_obj_t* wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_width(wrapper, LV_PCT(100));
|
||||
lv_obj_set_style_pad_all(wrapper, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_row(wrapper, 4, LV_PART_MAIN);
|
||||
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
|
||||
lv_obj_t* labelSsid = lv_label_create(wrapper);
|
||||
lv_label_set_text(labelSsid, "SSID:");
|
||||
lv_obj_set_style_text_color(labelSsid, lv_palette_main(LV_PALETTE_GREY), LV_PART_MAIN);
|
||||
|
||||
ctx->labelSsidValue = lv_label_create(wrapper);
|
||||
lv_obj_set_style_text_align(ctx->labelSsidValue, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN);
|
||||
lv_obj_set_width(ctx->labelSsidValue, LV_PCT(100));
|
||||
lv_label_set_long_mode(ctx->labelSsidValue, LV_LABEL_LONG_SCROLL);
|
||||
lv_obj_set_style_margin_hor(ctx->labelSsidValue, 2, LV_PART_MAIN);
|
||||
|
||||
lv_obj_t* labelPassword = lv_label_create(wrapper);
|
||||
lv_label_set_text(labelPassword, "Pass:");
|
||||
lv_obj_set_style_text_color(labelPassword, lv_palette_main(LV_PALETTE_GREY), LV_PART_MAIN);
|
||||
|
||||
ctx->labelPasswordValue = lv_label_create(wrapper);
|
||||
lv_obj_set_style_text_align(ctx->labelPasswordValue, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN);
|
||||
lv_obj_set_width(ctx->labelPasswordValue, LV_PCT(100));
|
||||
lv_label_set_long_mode(ctx->labelPasswordValue, LV_LABEL_LONG_SCROLL);
|
||||
lv_obj_set_style_margin_hor(ctx->labelPasswordValue, 2, LV_PART_MAIN);
|
||||
|
||||
lv_obj_t* labelIp = lv_label_create(wrapper);
|
||||
lv_label_set_text(labelIp, "IP:");
|
||||
lv_obj_set_style_text_color(labelIp, lv_palette_main(LV_PALETTE_GREY), LV_PART_MAIN);
|
||||
|
||||
ctx->labelIpValue = lv_label_create(wrapper);
|
||||
lv_obj_set_style_text_align(ctx->labelIpValue, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN);
|
||||
lv_obj_set_width(ctx->labelIpValue, LV_PCT(100));
|
||||
lv_label_set_long_mode(ctx->labelIpValue, LV_LABEL_LONG_SCROLL);
|
||||
lv_obj_set_style_margin_hor(ctx->labelIpValue, 2, LV_PART_MAIN);
|
||||
|
||||
// Start AP Mode and WebServer
|
||||
settings::webserver::WebServerSettings apSettings = ctx->wsSettings;
|
||||
apSettings.wifiMode = settings::webserver::WiFiMode::AccessPoint;
|
||||
apSettings.webServerEnabled = true;
|
||||
|
||||
if (apSettings.apSsid.empty()) {
|
||||
apSettings.apSsid = settings::webserver::generateDefaultApSsid();
|
||||
}
|
||||
|
||||
// Generate password if it's an open network or if password is empty
|
||||
if (apSettings.apOpenNetwork || apSettings.apPassword.empty()) {
|
||||
apSettings.apPassword = settings::webserver::generateRandomCredential(12);
|
||||
apSettings.apOpenNetwork = false;
|
||||
}
|
||||
|
||||
lv_label_set_text(ctx->labelSsidValue, apSettings.apSsid.c_str());
|
||||
lv_label_set_text(ctx->labelPasswordValue, apSettings.apPassword.c_str());
|
||||
lv_label_set_text(ctx->labelIpValue, "192.168.4.1");
|
||||
|
||||
// Apply settings and start services
|
||||
getMainDispatcher().dispatch([apSettings] {
|
||||
if (!settings::webserver::save(apSettings)) {
|
||||
LOG_E(TAG, "Failed to save AP settings");
|
||||
return;
|
||||
}
|
||||
service::webserver::getPubsub()->publish(service::webserver::WebServerEvent::WebServerSettingsChanged);
|
||||
service::webserver::setWebServerEnabled(true);
|
||||
});
|
||||
ctx->webServerEnabledChanged = true;
|
||||
}
|
||||
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
Context ctx {};
|
||||
ctx.appInstanceId = appInstanceId;
|
||||
ctx.wsSettings = settings::webserver::loadOrGetDefault();
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
|
||||
bool shouldClose = false;
|
||||
while (!shouldClose) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
switch (event.type) {
|
||||
case APP_EVENT_CLOSE:
|
||||
app_manager_finish(appInstanceId);
|
||||
shouldClose = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Equivalent of the old model's onHide(): persist the ORIGINAL settings (as loaded at
|
||||
// startup, not the temporary AP-mode config createWidgets() applied above) and revert the
|
||||
// web server's enabled state accordingly.
|
||||
const auto copy = ctx.wsSettings;
|
||||
const bool webServerChanged = ctx.webServerEnabledChanged;
|
||||
|
||||
getMainDispatcher().dispatch([copy, webServerChanged] {
|
||||
if (!settings::webserver::save(copy)) {
|
||||
LOG_W(TAG, "Failed to persist WebServer settings; changes may be lost on reboot");
|
||||
}
|
||||
|
||||
service::webserver::getPubsub()->publish(service::webserver::WebServerEvent::WebServerSettingsChanged);
|
||||
|
||||
if (webServerChanged) {
|
||||
LOG_I(TAG, "WebServer %s", copy.webServerEnabled ? "enabling..." : "disabling...");
|
||||
service::webserver::setWebServerEnabled(copy.webServerEnabled);
|
||||
}
|
||||
});
|
||||
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "ApWebServer",
|
||||
.name = "AP Web Server",
|
||||
.category = APP_CATEGORY_SYSTEM,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
|
||||
.flags = APP_MANIFEST_FLAG_HIDDEN,
|
||||
};
|
||||
|
||||
} // namespace tt::app::apwebserver
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
#include <Tactility/Tactility.h>
|
||||
#include <Tactility/PubSub.h>
|
||||
#include <Tactility/app/App.h>
|
||||
#include <Tactility/lvgl/Toolbar.h>
|
||||
#include <Tactility/service/audio/Audio.h>
|
||||
|
||||
#include <lvgl/icons/shared.h>
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <lvgl/lvgl.h>
|
||||
#include <lvgl/widgets/sliderbox.h>
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
|
||||
namespace tt::app::audiosettings {
|
||||
|
||||
class AudioSettingsApp final : public App {
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
namespace {
|
||||
|
||||
struct Context {
|
||||
uint32_t appInstanceId;
|
||||
PubSub<service::audio::AudioEvent>::SubscriptionHandle audioSubscription = nullptr;
|
||||
|
||||
lv_obj_t* inputEnabledSwitch = nullptr;
|
||||
@@ -21,195 +29,232 @@ class AudioSettingsApp final : public App {
|
||||
lv_obj_t* outputEnabledSwitch = nullptr;
|
||||
lv_obj_t* outputMuteSwitch = nullptr;
|
||||
lv_obj_t* outputVolumeSlider = nullptr;
|
||||
|
||||
static void onInputEnabledSwitch(lv_event_t* event) {
|
||||
auto* sw = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
bool enabled = lv_obj_has_state(sw, LV_STATE_CHECKED);
|
||||
service::audio::setInputEnabled(enabled);
|
||||
}
|
||||
|
||||
static void onOutputEnabledSwitch(lv_event_t* event) {
|
||||
auto* sw = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
bool enabled = lv_obj_has_state(sw, LV_STATE_CHECKED);
|
||||
service::audio::setOutputEnabled(enabled);
|
||||
}
|
||||
|
||||
static void onInputMuteSwitch(lv_event_t* event) {
|
||||
auto* sw = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
bool muted = lv_obj_has_state(sw, LV_STATE_CHECKED);
|
||||
service::audio::setInputMuted(muted);
|
||||
}
|
||||
|
||||
static void onOutputMuteSwitch(lv_event_t* event) {
|
||||
auto* sw = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
bool muted = lv_obj_has_state(sw, LV_STATE_CHECKED);
|
||||
service::audio::setOutputMuted(muted);
|
||||
}
|
||||
|
||||
static void onInputVolumeSlider(lv_event_t* event) {
|
||||
auto* sliderBox = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
float percent = static_cast<float>(lvgl_sliderbox_get_value(sliderBox));
|
||||
service::audio::setInputVolume(percent);
|
||||
}
|
||||
|
||||
static void onOutputVolumeSlider(lv_event_t* event) {
|
||||
auto* sliderBox = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
float percent = static_cast<float>(lvgl_sliderbox_get_value(sliderBox));
|
||||
service::audio::setOutputVolume(percent);
|
||||
}
|
||||
|
||||
static lv_obj_t* createSection(lv_obj_t* parent, const char* title) {
|
||||
auto* wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_size(wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_hor(wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* title_label = lv_label_create(wrapper);
|
||||
lv_label_set_text(title_label, title);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
static lv_obj_t* createSwitchRow(lv_obj_t* parent, const char* label, lv_event_cb_t cb, void* userData) {
|
||||
auto* row = lv_obj_create(parent);
|
||||
lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(row, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* row_label = lv_label_create(row);
|
||||
lv_label_set_text(row_label, label);
|
||||
lv_obj_align(row_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
|
||||
auto* sw = lv_switch_create(row);
|
||||
lv_obj_align(sw, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_obj_add_event_cb(sw, cb, LV_EVENT_VALUE_CHANGED, userData);
|
||||
|
||||
return sw;
|
||||
}
|
||||
|
||||
static lv_obj_t* createSliderRow(lv_obj_t* parent, const char* label, int32_t initialValue, lv_event_cb_t cb, void* userData) {
|
||||
auto* row = lv_obj_create(parent);
|
||||
lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(row, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* row_label = lv_label_create(row);
|
||||
lv_label_set_text(row_label, label);
|
||||
lv_obj_align(row_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
|
||||
auto* sliderBox = lvgl_sliderbox_create(row, 0, 100, 10, initialValue);
|
||||
lv_obj_set_width(sliderBox, LV_PCT(50));
|
||||
lv_obj_align(sliderBox, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lvgl_sliderbox_add_value_changed_cb(sliderBox, cb, userData);
|
||||
|
||||
return sliderBox;
|
||||
}
|
||||
|
||||
public:
|
||||
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override {
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
lvgl::toolbar_create(parent, app);
|
||||
|
||||
auto* main_wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_width(main_wrapper, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(main_wrapper, 1);
|
||||
|
||||
if (!service::audio::isAvailable()) {
|
||||
auto* label = lv_label_create(main_wrapper);
|
||||
lv_label_set_text(label, "No audio hardware available");
|
||||
lv_obj_center(label);
|
||||
return;
|
||||
}
|
||||
|
||||
// Gated per-direction, not just isAvailable() - a mic-only or speaker-only
|
||||
// device (e.g. a dedicated input codec with no output codec bound) should
|
||||
// only show the section it actually has, not a dead section for the other.
|
||||
if (service::audio::isInputAvailable()) {
|
||||
auto* input_section = createSection(main_wrapper, "Microphone");
|
||||
inputEnabledSwitch = createSwitchRow(input_section, "Enabled", onInputEnabledSwitch, this);
|
||||
inputMuteSwitch = createSwitchRow(input_section, "Mute", onInputMuteSwitch, this);
|
||||
inputVolumeSlider = createSliderRow(input_section, "Volume", static_cast<int32_t>(service::audio::getInputVolume()), onInputVolumeSlider, this);
|
||||
}
|
||||
|
||||
if (service::audio::isOutputAvailable()) {
|
||||
auto* output_section = createSection(main_wrapper, "Speaker");
|
||||
outputEnabledSwitch = createSwitchRow(output_section, "Enabled", onOutputEnabledSwitch, this);
|
||||
outputMuteSwitch = createSwitchRow(output_section, "Mute", onOutputMuteSwitch, this);
|
||||
outputVolumeSlider = createSliderRow(output_section, "Volume", static_cast<int32_t>(service::audio::getOutputVolume()), onOutputVolumeSlider, this);
|
||||
}
|
||||
|
||||
// isAvailable() only reflects that the audio-stream device exists, not that any
|
||||
// codec is actually bound to it (the stream device is constructed unconditionally
|
||||
// at module-start time, before devicetree codecs exist, and binds lazily on first
|
||||
// use) -- so a board with no input or output codec at all reaches here with both
|
||||
// sections skipped above and would otherwise show an empty page.
|
||||
if (!service::audio::isInputAvailable() && !service::audio::isOutputAvailable()) {
|
||||
auto* label = lv_label_create(main_wrapper);
|
||||
lv_label_set_text(label, "No supported audio controls");
|
||||
lv_obj_center(label);
|
||||
}
|
||||
|
||||
refresh();
|
||||
|
||||
audioSubscription = service::audio::getPubsub()->subscribe([this](auto) {
|
||||
lvgl_lock();
|
||||
refresh();
|
||||
lvgl_unlock();
|
||||
});
|
||||
}
|
||||
|
||||
void onHide(AppContext& app) override {
|
||||
if (audioSubscription != nullptr) {
|
||||
service::audio::getPubsub()->unsubscribe(audioSubscription);
|
||||
audioSubscription = nullptr;
|
||||
}
|
||||
|
||||
inputEnabledSwitch = nullptr;
|
||||
inputMuteSwitch = nullptr;
|
||||
inputVolumeSlider = nullptr;
|
||||
outputEnabledSwitch = nullptr;
|
||||
outputMuteSwitch = nullptr;
|
||||
outputVolumeSlider = nullptr;
|
||||
}
|
||||
|
||||
void refresh() const {
|
||||
if (inputEnabledSwitch) {
|
||||
if (service::audio::isInputEnabled()) lv_obj_add_state(inputEnabledSwitch, LV_STATE_CHECKED);
|
||||
else lv_obj_remove_state(inputEnabledSwitch, LV_STATE_CHECKED);
|
||||
}
|
||||
if (inputMuteSwitch) {
|
||||
if (service::audio::isInputMuted()) lv_obj_add_state(inputMuteSwitch, LV_STATE_CHECKED);
|
||||
else lv_obj_remove_state(inputMuteSwitch, LV_STATE_CHECKED);
|
||||
}
|
||||
if (inputVolumeSlider) {
|
||||
lvgl_sliderbox_set_value(inputVolumeSlider, static_cast<int32_t>(service::audio::getInputVolume()), LV_ANIM_OFF);
|
||||
}
|
||||
|
||||
if (outputEnabledSwitch) {
|
||||
if (service::audio::isOutputEnabled()) lv_obj_add_state(outputEnabledSwitch, LV_STATE_CHECKED);
|
||||
else lv_obj_remove_state(outputEnabledSwitch, LV_STATE_CHECKED);
|
||||
}
|
||||
if (outputMuteSwitch) {
|
||||
if (service::audio::isOutputMuted()) lv_obj_add_state(outputMuteSwitch, LV_STATE_CHECKED);
|
||||
else lv_obj_remove_state(outputMuteSwitch, LV_STATE_CHECKED);
|
||||
}
|
||||
if (outputVolumeSlider) {
|
||||
lvgl_sliderbox_set_value(outputVolumeSlider, static_cast<int32_t>(service::audio::getOutputVolume()), LV_ANIM_OFF);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "AudioSettings",
|
||||
.appName = "Audio",
|
||||
.appIcon = LVGL_ICON_SHARED_MUSIC_NOTE,
|
||||
.appCategory = Category::Settings,
|
||||
.createApp = create<AudioSettingsApp>
|
||||
|
||||
void onBackPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
|
||||
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
|
||||
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
|
||||
// deadlock against itself.
|
||||
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(ctx->appInstanceId, &closeEvent);
|
||||
}
|
||||
|
||||
void onInputEnabledSwitch(lv_event_t* event) {
|
||||
auto* sw = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
bool enabled = lv_obj_has_state(sw, LV_STATE_CHECKED);
|
||||
service::audio::setInputEnabled(enabled);
|
||||
}
|
||||
|
||||
void onOutputEnabledSwitch(lv_event_t* event) {
|
||||
auto* sw = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
bool enabled = lv_obj_has_state(sw, LV_STATE_CHECKED);
|
||||
service::audio::setOutputEnabled(enabled);
|
||||
}
|
||||
|
||||
void onInputMuteSwitch(lv_event_t* event) {
|
||||
auto* sw = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
bool muted = lv_obj_has_state(sw, LV_STATE_CHECKED);
|
||||
service::audio::setInputMuted(muted);
|
||||
}
|
||||
|
||||
void onOutputMuteSwitch(lv_event_t* event) {
|
||||
auto* sw = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
bool muted = lv_obj_has_state(sw, LV_STATE_CHECKED);
|
||||
service::audio::setOutputMuted(muted);
|
||||
}
|
||||
|
||||
void onInputVolumeSlider(lv_event_t* event) {
|
||||
auto* sliderBox = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
float percent = static_cast<float>(lvgl_sliderbox_get_value(sliderBox));
|
||||
service::audio::setInputVolume(percent);
|
||||
}
|
||||
|
||||
void onOutputVolumeSlider(lv_event_t* event) {
|
||||
auto* sliderBox = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
float percent = static_cast<float>(lvgl_sliderbox_get_value(sliderBox));
|
||||
service::audio::setOutputVolume(percent);
|
||||
}
|
||||
|
||||
lv_obj_t* createSection(lv_obj_t* parent, const char* title) {
|
||||
auto* wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_size(wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_hor(wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* title_label = lv_label_create(wrapper);
|
||||
lv_label_set_text(title_label, title);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
lv_obj_t* createSwitchRow(lv_obj_t* parent, const char* label, lv_event_cb_t cb, void* userData) {
|
||||
auto* row = lv_obj_create(parent);
|
||||
lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(row, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* row_label = lv_label_create(row);
|
||||
lv_label_set_text(row_label, label);
|
||||
lv_obj_align(row_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
|
||||
auto* sw = lv_switch_create(row);
|
||||
lv_obj_align(sw, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_obj_add_event_cb(sw, cb, LV_EVENT_VALUE_CHANGED, userData);
|
||||
|
||||
return sw;
|
||||
}
|
||||
|
||||
lv_obj_t* createSliderRow(lv_obj_t* parent, const char* label, int32_t initialValue, lv_event_cb_t cb, void* userData) {
|
||||
auto* row = lv_obj_create(parent);
|
||||
lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(row, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* row_label = lv_label_create(row);
|
||||
lv_label_set_text(row_label, label);
|
||||
lv_obj_align(row_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
|
||||
auto* sliderBox = lvgl_sliderbox_create(row, 0, 100, 10, initialValue);
|
||||
lv_obj_set_width(sliderBox, LV_PCT(50));
|
||||
lv_obj_align(sliderBox, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lvgl_sliderbox_add_value_changed_cb(sliderBox, cb, userData);
|
||||
|
||||
return sliderBox;
|
||||
}
|
||||
|
||||
void refresh(Context* ctx) {
|
||||
if (ctx->inputEnabledSwitch) {
|
||||
if (service::audio::isInputEnabled()) lv_obj_add_state(ctx->inputEnabledSwitch, LV_STATE_CHECKED);
|
||||
else lv_obj_remove_state(ctx->inputEnabledSwitch, LV_STATE_CHECKED);
|
||||
}
|
||||
if (ctx->inputMuteSwitch) {
|
||||
if (service::audio::isInputMuted()) lv_obj_add_state(ctx->inputMuteSwitch, LV_STATE_CHECKED);
|
||||
else lv_obj_remove_state(ctx->inputMuteSwitch, LV_STATE_CHECKED);
|
||||
}
|
||||
if (ctx->inputVolumeSlider) {
|
||||
lvgl_sliderbox_set_value(ctx->inputVolumeSlider, static_cast<int32_t>(service::audio::getInputVolume()), LV_ANIM_OFF);
|
||||
}
|
||||
|
||||
if (ctx->outputEnabledSwitch) {
|
||||
if (service::audio::isOutputEnabled()) lv_obj_add_state(ctx->outputEnabledSwitch, LV_STATE_CHECKED);
|
||||
else lv_obj_remove_state(ctx->outputEnabledSwitch, LV_STATE_CHECKED);
|
||||
}
|
||||
if (ctx->outputMuteSwitch) {
|
||||
if (service::audio::isOutputMuted()) lv_obj_add_state(ctx->outputMuteSwitch, LV_STATE_CHECKED);
|
||||
else lv_obj_remove_state(ctx->outputMuteSwitch, LV_STATE_CHECKED);
|
||||
}
|
||||
if (ctx->outputVolumeSlider) {
|
||||
lvgl_sliderbox_set_value(ctx->outputVolumeSlider, static_cast<int32_t>(service::audio::getOutputVolume()), LV_ANIM_OFF);
|
||||
}
|
||||
}
|
||||
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* toolbar = lvgl_toolbar_create(parent, "Audio");
|
||||
// The global toolbar nav callback only knows how to stop old-model apps.
|
||||
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
|
||||
|
||||
auto* main_wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_width(main_wrapper, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(main_wrapper, 1);
|
||||
|
||||
if (!service::audio::isAvailable()) {
|
||||
auto* label = lv_label_create(main_wrapper);
|
||||
lv_label_set_text(label, "No audio hardware available");
|
||||
lv_obj_center(label);
|
||||
return;
|
||||
}
|
||||
|
||||
// Gated per-direction, not just isAvailable() - a mic-only or speaker-only
|
||||
// device (e.g. a dedicated input codec with no output codec bound) should
|
||||
// only show the section it actually has, not a dead section for the other.
|
||||
if (service::audio::isInputAvailable()) {
|
||||
auto* input_section = createSection(main_wrapper, "Microphone");
|
||||
ctx->inputEnabledSwitch = createSwitchRow(input_section, "Enabled", onInputEnabledSwitch, ctx);
|
||||
ctx->inputMuteSwitch = createSwitchRow(input_section, "Mute", onInputMuteSwitch, ctx);
|
||||
ctx->inputVolumeSlider = createSliderRow(input_section, "Volume", static_cast<int32_t>(service::audio::getInputVolume()), onInputVolumeSlider, ctx);
|
||||
}
|
||||
|
||||
if (service::audio::isOutputAvailable()) {
|
||||
auto* output_section = createSection(main_wrapper, "Speaker");
|
||||
ctx->outputEnabledSwitch = createSwitchRow(output_section, "Enabled", onOutputEnabledSwitch, ctx);
|
||||
ctx->outputMuteSwitch = createSwitchRow(output_section, "Mute", onOutputMuteSwitch, ctx);
|
||||
ctx->outputVolumeSlider = createSliderRow(output_section, "Volume", static_cast<int32_t>(service::audio::getOutputVolume()), onOutputVolumeSlider, ctx);
|
||||
}
|
||||
|
||||
// isAvailable() only reflects that the audio-stream device exists, not that any
|
||||
// codec is actually bound to it (the stream device is constructed unconditionally
|
||||
// at module-start time, before devicetree codecs exist, and binds lazily on first
|
||||
// use) -- so a board with no input or output codec at all reaches here with both
|
||||
// sections skipped above and would otherwise show an empty page.
|
||||
if (!service::audio::isInputAvailable() && !service::audio::isOutputAvailable()) {
|
||||
auto* label = lv_label_create(main_wrapper);
|
||||
lv_label_set_text(label, "No supported audio controls");
|
||||
lv_obj_center(label);
|
||||
}
|
||||
|
||||
refresh(ctx);
|
||||
|
||||
ctx->audioSubscription = service::audio::getPubsub()->subscribe([ctx](auto) {
|
||||
lvgl_lock();
|
||||
refresh(ctx);
|
||||
lvgl_unlock();
|
||||
});
|
||||
}
|
||||
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
Context ctx {};
|
||||
ctx.appInstanceId = appInstanceId;
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
|
||||
bool shouldClose = false;
|
||||
while (!shouldClose) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
switch (event.type) {
|
||||
case APP_EVENT_CLOSE:
|
||||
app_manager_finish(appInstanceId);
|
||||
shouldClose = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.audioSubscription != nullptr) {
|
||||
service::audio::getPubsub()->unsubscribe(ctx.audioSubscription);
|
||||
ctx.audioSubscription = nullptr;
|
||||
}
|
||||
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "AudioSettings",
|
||||
.name = "Audio",
|
||||
.category = APP_CATEGORY_SETTINGS,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
|
||||
};
|
||||
|
||||
} // namespace tt::app::audiosettings
|
||||
|
||||
+272
-216
@@ -1,28 +1,30 @@
|
||||
#include "tactility/system_event.h"
|
||||
|
||||
|
||||
#include <tactility/delay.h>
|
||||
#include <tactility/drivers/backlight.h>
|
||||
#include <tactility/drivers/display.h>
|
||||
#include <tactility/log.h>
|
||||
#include <tactility/time.h>
|
||||
|
||||
#include <Tactility/CpuAffinity.h>
|
||||
#include <Tactility/Paths.h>
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <Tactility/DeprecatedPaths.h>
|
||||
#include <Tactility/MountPoints.h>
|
||||
#include <Tactility/TactilityPrivate.h>
|
||||
#include <Tactility/app/AppContext.h>
|
||||
#include <Tactility/app/AppPaths.h>
|
||||
#include <Tactility/app/alertdialog/AlertDialog.h>
|
||||
#include <Tactility/hal/usb/Usb.h>
|
||||
#include <Tactility/lvgl/Lvgl.h>
|
||||
#include <Tactility/lvgl/Style.h>
|
||||
#include <Tactility/service/loader/Loader.h>
|
||||
#include <Tactility/settings/BootSettings.h>
|
||||
#include <Tactility/settings/DisplaySettings.h>
|
||||
|
||||
#include <lvgl.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <format>
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
#include <Tactility/app/crashdiagnostics/CrashDiagnostics.h>
|
||||
@@ -37,243 +39,297 @@ namespace tt::app::boot {
|
||||
|
||||
constexpr auto* TAG = "Boot";
|
||||
|
||||
extern const AppManifest manifest;
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
class BootApp : public App {
|
||||
namespace {
|
||||
|
||||
// Snapshot of hal::usb::isUsbBootMode(), taken before the boot thread starts and
|
||||
// potentially clears the underlying flag via setupUsbBootMode()/resetUsbBootMode().
|
||||
// onShow() reads this instead of the live flag to avoid a race between the two.
|
||||
static std::atomic<bool> isUsbBootSplash;
|
||||
// Snapshot of hal::usb::isUsbBootMode(), taken before boot work starts and potentially clears
|
||||
// the underlying flag via setupUsbBootMode()/resetUsbBootMode(). createSplashWidgets() reads
|
||||
// this instead of the live flag to avoid a race between the two.
|
||||
std::atomic<bool> isUsbBootSplash = false;
|
||||
|
||||
// Set by bootThreadCallback() when CONFIG_TT_USER_DATA_LOCATION_SD is defined but no SD card is mounted.
|
||||
// onShow() reads this to show an error instead of the normal splash, and boot halts instead of starting the launcher.
|
||||
static std::atomic<bool> sdCardMissing;
|
||||
// Set when CONFIG_TT_USER_DATA_LOCATION_SD is defined but no SD card is mounted. Switches the
|
||||
// window to an error screen and halts before starting the next app.
|
||||
std::atomic<bool> sdCardMissing = false;
|
||||
|
||||
Thread thread = Thread(
|
||||
"boot",
|
||||
5120,
|
||||
[] { return bootThreadCallback(); },
|
||||
getCpuAffinityConfiguration().system
|
||||
);
|
||||
|
||||
static void setupDisplay() {
|
||||
Device* display = nullptr;
|
||||
if (device_get_first_by_type(&DISPLAY_TYPE, &display) == ERROR_NONE) {
|
||||
Device* backlight;
|
||||
if (display_get_backlight(display, &backlight) == ERROR_NONE) {
|
||||
if (!device_is_ready(backlight)) {
|
||||
if (device_start(backlight) != ERROR_NONE) {
|
||||
LOG_E(TAG, "Failed to start %s", backlight->name);
|
||||
}
|
||||
}
|
||||
|
||||
settings::display::DisplaySettings settings;
|
||||
if (settings::display::load(settings)) {
|
||||
} else {
|
||||
settings = settings::display::getDefault();
|
||||
}
|
||||
|
||||
if (backlight_set_brightness(backlight, settings.backlightDuty) == ERROR_NONE) {
|
||||
LOG_I(TAG, "Backlight for %s set to %d", display->name, settings.backlightDuty);
|
||||
} else {
|
||||
LOG_E(TAG, "Failed to set brightness of %s", backlight->name);
|
||||
}
|
||||
} else {
|
||||
LOG_I(TAG, "No backlight for %s", display->name);
|
||||
}
|
||||
device_put(display);
|
||||
} else {
|
||||
LOG_I(TAG, "No kernel display");
|
||||
}
|
||||
}
|
||||
|
||||
static bool setupUsbBootMode() {
|
||||
if (!hal::usb::isUsbBootMode()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
LOG_I(TAG, "Rebooting into mass storage device mode");
|
||||
auto mode = hal::usb::getUsbBootMode(); // Get mode before reset
|
||||
hal::usb::resetUsbBootMode();
|
||||
if (mode == hal::usb::BootMode::Flash) {
|
||||
if (!hal::usb::startMassStorageWithFlash(true)) {
|
||||
LOG_E(TAG, "Unable to start flash mass storage");
|
||||
return false;
|
||||
}
|
||||
} else if (mode == hal::usb::BootMode::Sdmmc) {
|
||||
if (!hal::usb::startMassStorageWithSdmmc(true)) {
|
||||
LOG_E(TAG, "Unable to start SD mass storage");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static void waitForMinimalSplashDuration(TickType_t startTime) {
|
||||
const auto end_time = get_ticks();
|
||||
const auto ticks_passed = end_time - startTime;
|
||||
constexpr auto minimum_ticks = (CONFIG_TT_SPLASH_DURATION / portTICK_PERIOD_MS);
|
||||
if (minimum_ticks > ticks_passed) {
|
||||
delay_ticks(minimum_ticks - ticks_passed);
|
||||
}
|
||||
}
|
||||
|
||||
static int32_t bootThreadCallback() {
|
||||
LOG_I(TAG, "Starting boot thread");
|
||||
const auto start_time = get_ticks();
|
||||
|
||||
// Give the UI some time to redraw
|
||||
// If we don't do this, various init calls will read files and block SPI IO for the display
|
||||
// This would result in a blank/black screen being shown during this phase of the boot process
|
||||
// This works with 5 ms on a T-Lora Pager, so we give it 10 ms to be safe
|
||||
delay_millis(10);
|
||||
|
||||
// TODO: Support for multiple displays
|
||||
LOG_I(TAG, "Setup display");
|
||||
setupDisplay();
|
||||
LOG_I(TAG, "Prepare file systems");
|
||||
prepareFileSystems();
|
||||
|
||||
#ifdef CONFIG_TT_USER_DATA_LOCATION_SD
|
||||
std::string sd_path;
|
||||
if (!findFirstMountedSdCardPath(sd_path)) {
|
||||
LOG_E(TAG, "SD card not found");
|
||||
sdCardMissing = true;
|
||||
}
|
||||
#endif
|
||||
|
||||
if (!setupUsbBootMode()) {
|
||||
LOG_I(TAG, "initFromBootApp");
|
||||
registerApps();
|
||||
waitForMinimalSplashDuration(start_time);
|
||||
// When SD card is missing, wait for dialog result
|
||||
if (!sdCardMissing) stop(manifest.appId);
|
||||
startNextApp();
|
||||
}
|
||||
|
||||
// This event will likely block as other systems are initialized
|
||||
// e.g. Wi-Fi reads AP configs from SD card
|
||||
LOG_I(TAG, "Publish event");
|
||||
system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static std::string getLauncherAppId() {
|
||||
settings::BootSettings boot_properties;
|
||||
// When boot.properties hasn't been overridden, return default
|
||||
if (!settings::loadBootSettings(boot_properties)) {
|
||||
return CONFIG_TT_LAUNCHER_APP_ID;
|
||||
}
|
||||
|
||||
// When boot properties didn't specify an override, return default
|
||||
if (boot_properties.launcherAppId.empty()) {
|
||||
LOG_E(TAG, "Failed to load launcher configuration, or launcher not configured");
|
||||
return CONFIG_TT_LAUNCHER_APP_ID;
|
||||
}
|
||||
|
||||
// If the app in the boot.properties does not exist, return default
|
||||
if (findAppManifestById(boot_properties.launcherAppId) == nullptr) {
|
||||
LOG_E(TAG, "Launcher app %s not found", boot_properties.launcherAppId.c_str());
|
||||
return CONFIG_TT_LAUNCHER_APP_ID;
|
||||
}
|
||||
|
||||
// The boot.properties launcher app id is valid
|
||||
return boot_properties.launcherAppId;
|
||||
}
|
||||
|
||||
static void startNextApp() {
|
||||
if (sdCardMissing) {
|
||||
alertdialog::start("Error", "SD card not found.\nPlease insert one and reboot.", std::vector<const char*> { "Reboot" });
|
||||
return;
|
||||
}
|
||||
uint32_t bootAppInstanceId = 0;
|
||||
WindowId bootWindowId = 0;
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
if (esp_reset_reason() == ESP_RST_PANIC) {
|
||||
crashdiagnostics::start();
|
||||
return;
|
||||
}
|
||||
constexpr auto PARTITION_PREFIX = std::string("/");
|
||||
#else
|
||||
constexpr auto PARTITION_PREFIX = std::string("");
|
||||
#endif
|
||||
auto launcher_app_id = getLauncherAppId();
|
||||
start(launcher_app_id);
|
||||
|
||||
// Equivalent of AppPaths::getAssetsPath() for the internal "Boot" app id, without needing a
|
||||
// live AppContext (which this app no longer has under the new app-module model).
|
||||
std::string getBootAssetsPath(const std::string& childPath) {
|
||||
return std::format("{}{}/app/Boot/assets/{}", PARTITION_PREFIX, file::SYSTEM_PARTITION_NAME, childPath);
|
||||
}
|
||||
|
||||
void setupDisplay() {
|
||||
// TODO: Support for multiple displays
|
||||
|
||||
Device* display = nullptr;
|
||||
if (device_get_first_by_type(&DISPLAY_TYPE, &display) != ERROR_NONE) {
|
||||
LOG_I(TAG, "No kernel display");
|
||||
return;
|
||||
}
|
||||
|
||||
static int getSmallestDimension() {
|
||||
auto* display = lv_display_get_default();
|
||||
int width = lv_display_get_horizontal_resolution(display);
|
||||
int height = lv_display_get_vertical_resolution(display);
|
||||
return std::min(width, height);
|
||||
// Set backlight brightness
|
||||
Device* backlight;
|
||||
if (display_get_backlight(display, &backlight) == ERROR_NONE) {
|
||||
if (!device_is_ready(backlight)) {
|
||||
if (device_start(backlight) != ERROR_NONE) {
|
||||
LOG_E(TAG, "Failed to start %s", backlight->name);
|
||||
}
|
||||
}
|
||||
|
||||
settings::display::DisplaySettings settings;
|
||||
if (settings::display::load(settings)) {
|
||||
} else {
|
||||
settings = settings::display::getDefault();
|
||||
}
|
||||
|
||||
if (backlight_set_brightness(backlight, settings.backlightDuty) == ERROR_NONE) {
|
||||
LOG_I(TAG, "Backlight for %s set to %d", display->name, settings.backlightDuty);
|
||||
} else {
|
||||
LOG_E(TAG, "Failed to set brightness of %s", backlight->name);
|
||||
}
|
||||
} else {
|
||||
LOG_I(TAG, "No backlight for %s", display->name);
|
||||
}
|
||||
|
||||
public:
|
||||
device_put(display);
|
||||
}
|
||||
|
||||
void onCreate(AppContext& app) override {
|
||||
// Snapshot before the boot thread potentially clears the flag via setupUsbBootMode()
|
||||
isUsbBootSplash = hal::usb::isUsbBootMode();
|
||||
bool setupUsbBootMode() {
|
||||
if (!hal::usb::isUsbBootMode()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Just in case this app is somehow resumed
|
||||
if (thread.getState() == Thread::State::Stopped) {
|
||||
thread.start();
|
||||
LOG_I(TAG, "Rebooting into mass storage device mode");
|
||||
auto mode = hal::usb::getUsbBootMode(); // Get mode before reset
|
||||
hal::usb::resetUsbBootMode();
|
||||
if (mode == hal::usb::BootMode::Flash) {
|
||||
if (!hal::usb::startMassStorageWithFlash(true)) {
|
||||
LOG_E(TAG, "Unable to start flash mass storage");
|
||||
return false;
|
||||
}
|
||||
} else if (mode == hal::usb::BootMode::Sdmmc) {
|
||||
if (!hal::usb::startMassStorageWithSdmmc(true)) {
|
||||
LOG_E(TAG, "Unable to start SD mass storage");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void onDestroy(AppContext& app) override {
|
||||
thread.join();
|
||||
return true;
|
||||
}
|
||||
|
||||
void waitForMinimalSplashDuration(TickType_t startTime) {
|
||||
const auto end_time = get_ticks();
|
||||
const auto ticks_passed = end_time - startTime;
|
||||
constexpr auto minimum_ticks = (CONFIG_TT_SPLASH_DURATION / portTICK_PERIOD_MS);
|
||||
if (minimum_ticks > ticks_passed) {
|
||||
delay_ticks(minimum_ticks - ticks_passed);
|
||||
}
|
||||
}
|
||||
|
||||
std::string getLauncherAppId() {
|
||||
settings::BootSettings boot_properties;
|
||||
// When boot.properties hasn't been overridden, return default
|
||||
if (!settings::loadBootSettings(boot_properties)) {
|
||||
return CONFIG_TT_LAUNCHER_APP_ID;
|
||||
}
|
||||
|
||||
void onResult(AppContext& /*app*/, LaunchId /*launchId*/, Result /*result*/, std::unique_ptr<Bundle> /*bundle*/) override {
|
||||
// When boot properties didn't specify an override, return default
|
||||
if (boot_properties.launcherAppId.empty()) {
|
||||
LOG_E(TAG, "Failed to load launcher configuration, or launcher not configured");
|
||||
return CONFIG_TT_LAUNCHER_APP_ID;
|
||||
}
|
||||
|
||||
// If the app in the boot.properties does not exist, return default
|
||||
if (app_manager_find_manifest(boot_properties.launcherAppId.c_str()) == nullptr) {
|
||||
LOG_E(TAG, "Launcher app %s not found", boot_properties.launcherAppId.c_str());
|
||||
return CONFIG_TT_LAUNCHER_APP_ID;
|
||||
}
|
||||
|
||||
// The boot.properties launcher app id is valid
|
||||
return boot_properties.launcherAppId;
|
||||
}
|
||||
|
||||
int getSmallestDimension() {
|
||||
auto* display = lv_display_get_default();
|
||||
int width = lv_display_get_horizontal_resolution(display);
|
||||
int height = lv_display_get_vertical_resolution(display);
|
||||
return std::min(width, height);
|
||||
}
|
||||
|
||||
void createSplashWidgets(lv_obj_t* root, void*) {
|
||||
lvgl::obj_set_style_bg_blacken(root);
|
||||
lv_obj_set_style_border_width(root, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_radius(root, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* image = lv_image_create(root);
|
||||
lv_obj_set_size(image, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
||||
lv_obj_align(image, LV_ALIGN_CENTER, 0, 0);
|
||||
|
||||
const char* logo;
|
||||
// TODO: Replace with automatic asset buckets like on Android
|
||||
if (getSmallestDimension() < 150) { // e.g. Cardputer
|
||||
logo = isUsbBootSplash ? "logo_usb.png" : "logo_small.png";
|
||||
} else {
|
||||
logo = isUsbBootSplash ? "logo_usb.png" : "logo.png";
|
||||
}
|
||||
const auto logo_path = lvgl::PATH_PREFIX + getBootAssetsPath(logo);
|
||||
LOG_I(TAG, "%s", logo_path.c_str());
|
||||
lv_image_set_src(image, logo_path.c_str());
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
if (isUsbBootSplash) {
|
||||
auto* button = lv_button_create(root);
|
||||
lv_obj_align(button, LV_ALIGN_BOTTOM_MID, 0, -16);
|
||||
auto* label = lv_label_create(button);
|
||||
lv_label_set_text(label, "Return to OS");
|
||||
lv_obj_add_event_cb(button, [](lv_event_t*) {
|
||||
hal::usb::stop();
|
||||
esp_restart();
|
||||
}, LV_EVENT_SHORT_CLICKED, nullptr);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void createSdCardMissingWidgets(lv_obj_t* root, void*) {
|
||||
lvgl::obj_set_style_bg_blacken(root);
|
||||
lv_obj_set_style_border_width(root, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_radius(root, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(root, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
|
||||
auto* label = lv_label_create(root);
|
||||
lv_label_set_text(label, "SD card not found.\nPlease insert one and reboot.");
|
||||
lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_text_color(label, lv_color_white(), LV_STATE_DEFAULT);
|
||||
|
||||
auto* button = lv_button_create(root);
|
||||
lv_obj_set_style_margin_top(button, 16, LV_STATE_DEFAULT);
|
||||
auto* button_label = lv_label_create(button);
|
||||
lv_label_set_text(button_label, "Reboot");
|
||||
lv_obj_add_event_cb(button, [](lv_event_t*) {
|
||||
#ifdef ESP_PLATFORM
|
||||
esp_restart();
|
||||
#endif
|
||||
}, LV_EVENT_SHORT_CLICKED, nullptr);
|
||||
}
|
||||
|
||||
// Replaces the splash with a self-contained error screen (no dependency on the old alertdialog
|
||||
// app - this app has no parent in the old App stack to deliver a result back to).
|
||||
void showSdCardMissingScreen() {
|
||||
if (bootWindowId != 0) {
|
||||
window_manager_remove(bootWindowId);
|
||||
}
|
||||
bootWindowId = window_manager_create(bootAppInstanceId, createSdCardMissingWidgets, nullptr);
|
||||
}
|
||||
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override {
|
||||
lvgl::obj_set_style_bg_blacken(parent);
|
||||
lv_obj_set_style_border_width(parent, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_radius(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* image = lv_image_create(parent);
|
||||
lv_obj_set_size(image, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
||||
lv_obj_align(image, LV_ALIGN_CENTER, 0, 0);
|
||||
|
||||
const auto paths = app.getPaths();
|
||||
const char* logo;
|
||||
// TODO: Replace with automatic asset buckets like on Android
|
||||
if (getSmallestDimension() < 150) { // e.g. Cardputer
|
||||
logo = isUsbBootSplash ? "logo_usb.png" : "logo_small.png";
|
||||
} else {
|
||||
logo = isUsbBootSplash ? "logo_usb.png" : "logo.png";
|
||||
}
|
||||
const auto logo_path = lvgl::PATH_PREFIX + paths->getAssetsPath(logo);
|
||||
LOG_I(TAG, "%s", logo_path.c_str());
|
||||
lv_image_set_src(image, logo_path.c_str());
|
||||
void startNextApp() {
|
||||
if (sdCardMissing) {
|
||||
showSdCardMissingScreen();
|
||||
return;
|
||||
}
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
if (isUsbBootSplash) {
|
||||
auto* button = lv_button_create(parent);
|
||||
lv_obj_align(button, LV_ALIGN_BOTTOM_MID, 0, -16);
|
||||
auto* label = lv_label_create(button);
|
||||
lv_label_set_text(label, "Return to OS");
|
||||
lv_obj_add_event_cb(button, [](lv_event_t*) {
|
||||
hal::usb::stop();
|
||||
esp_restart();
|
||||
}, LV_EVENT_SHORT_CLICKED, nullptr);
|
||||
}
|
||||
#endif
|
||||
if (esp_reset_reason() == ESP_RST_PANIC) {
|
||||
crashdiagnostics::start(); // fire-and-forget; no result expected back
|
||||
return;
|
||||
}
|
||||
};
|
||||
#endif
|
||||
|
||||
std::atomic<bool> BootApp::isUsbBootSplash = false;
|
||||
std::atomic<bool> BootApp::sdCardMissing = false;
|
||||
auto launcher_app_id = getLauncherAppId();
|
||||
uint32_t launcher_instance_id = 0;
|
||||
app_manager_start(launcher_app_id.c_str(), &launcher_instance_id);
|
||||
}
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "Boot",
|
||||
.appName = "Boot",
|
||||
.appCategory = Category::System,
|
||||
.appFlags = AppManifest::Flags::HideStatusBar | AppManifest::Flags::Hidden,
|
||||
.createApp = create<BootApp>
|
||||
void runBootSequence(TickType_t startTime) {
|
||||
LOG_I(TAG, "Starting boot sequence");
|
||||
|
||||
// Give the UI some time to redraw
|
||||
// If we don't do this, various init calls will read files and block SPI IO for the display
|
||||
// This would result in a blank/black screen being shown during this phase of the boot process
|
||||
// This works with 5 ms on a T-Lora Pager, so we give it 10 ms to be safe
|
||||
delay_millis(10);
|
||||
|
||||
LOG_I(TAG, "Setup display");
|
||||
setupDisplay();
|
||||
LOG_I(TAG, "Prepare file systems");
|
||||
prepareFileSystems();
|
||||
|
||||
#ifdef CONFIG_TT_USER_DATA_LOCATION_SD
|
||||
std::string sd_path;
|
||||
if (!findFirstMountedSdCardPath(sd_path)) {
|
||||
LOG_E(TAG, "SD card not found");
|
||||
sdCardMissing = true;
|
||||
}
|
||||
#endif
|
||||
|
||||
if (!setupUsbBootMode()) {
|
||||
LOG_I(TAG, "initFromBootApp");
|
||||
registerApps();
|
||||
waitForMinimalSplashDuration(startTime);
|
||||
startNextApp();
|
||||
}
|
||||
|
||||
// This event will likely block as other systems are initialized
|
||||
// e.g. Wi-Fi reads AP configs from SD card
|
||||
LOG_I(TAG, "Publish event");
|
||||
system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0);
|
||||
}
|
||||
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
bootAppInstanceId = appInstanceId;
|
||||
const auto start_time = get_ticks();
|
||||
|
||||
// Snapshot before runBootSequence() potentially clears the flag via setupUsbBootMode()
|
||||
isUsbBootSplash = hal::usb::isUsbBootMode();
|
||||
sdCardMissing = false;
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
bootWindowId = window_manager_create(appInstanceId, createSplashWidgets, nullptr);
|
||||
|
||||
runBootSequence(start_time);
|
||||
|
||||
// Waits until app_manager_start(launcher) (or a permanent stop) tells us to give up -
|
||||
// startNextApp() above is what triggers that, via app-module's "save the previously active
|
||||
// app" policy, unless sdCardMissing halted before it.
|
||||
while (true) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
if (event.type == APP_EVENT_CLOSE) {
|
||||
app_manager_finish(appInstanceId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (bootWindowId != 0) {
|
||||
window_manager_remove(bootWindowId);
|
||||
}
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "Boot",
|
||||
.name = "Boot",
|
||||
.category = APP_CATEGORY_SYSTEM,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
|
||||
.flags = APP_MANIFEST_FLAG_HIDDEN,
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -4,20 +4,25 @@
|
||||
#include <Tactility/app/btmanage/View.h>
|
||||
|
||||
#include <Tactility/Tactility.h>
|
||||
#include <Tactility/app/AppContext.h>
|
||||
#include <Tactility/app/AppManifest.h>
|
||||
|
||||
#include <lvgl/icons/shared.h>
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <tactility/log.h>
|
||||
|
||||
namespace tt::app::btmanage {
|
||||
|
||||
constexpr auto* TAG = "BtManage";
|
||||
|
||||
extern const AppManifest manifest;
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
static void onBtToggled(bool requestOn) {
|
||||
|
||||
static void onBtToggled(void* context, bool requestOn) {
|
||||
#if defined(CONFIG_BT_NIMBLE_ENABLED)
|
||||
auto* ctx = static_cast<Context*>(context);
|
||||
Device* dev;
|
||||
if (device_get_first_by_type(&BLUETOOTH_TYPE, &dev) == ERROR_NONE) {
|
||||
bool radio_on = bluetooth::isRadioOnOrPending(dev);
|
||||
@@ -25,17 +30,15 @@ static void onBtToggled(bool requestOn) {
|
||||
LOG_I(TAG, "Turning on");
|
||||
if (bluetooth::start(dev)) {
|
||||
// The driver only allocates its callback list once the device is started,
|
||||
// so the registration attempted in onShow() (while radio was off) was a
|
||||
// so the registration attempted at startup (while radio was off) was a
|
||||
// no-op. Register again now that the device is actually up.
|
||||
auto bt = std::static_pointer_cast<BtManage>(getCurrentApp());
|
||||
bt->registerDeviceCallback(dev);
|
||||
registerDeviceCallback(ctx, dev);
|
||||
}
|
||||
} else if (!requestOn && radio_on) {
|
||||
LOG_I(TAG, "Turning off");
|
||||
if (bluetooth::stop(dev)) {
|
||||
// A completed stop frees the driver's callback list.
|
||||
auto bt = std::static_pointer_cast<BtManage>(getCurrentApp());
|
||||
bt->forgetCallbackRegistration();
|
||||
forgetCallbackRegistration(ctx);
|
||||
}
|
||||
}
|
||||
device_put(dev);
|
||||
@@ -46,7 +49,7 @@ static void onBtToggled(bool requestOn) {
|
||||
#endif
|
||||
}
|
||||
|
||||
static void onScanToggled(bool enabled) {
|
||||
static void onScanToggled(void* /*context*/, bool enabled) {
|
||||
Device* dev;
|
||||
if (device_get_first_active_by_type(&BLUETOOTH_TYPE, &dev) != ERROR_NONE) {
|
||||
LOG_W(TAG, "Scan: No bluetooth device found");
|
||||
@@ -70,7 +73,7 @@ static void onDisconnectPeer(const std::array<uint8_t, 6>& addr, int profileId)
|
||||
bluetooth::disconnect(addr, profileId);
|
||||
}
|
||||
|
||||
static void onPairPeer(const std::array<uint8_t, 6>& addr) {
|
||||
static void onPairPeer(void* /*context*/, const std::array<uint8_t, 6>& addr) {
|
||||
// Clicking an unrecognised scan result initiates a HID host connection.
|
||||
// Bond exchange happens automatically during the first connection.
|
||||
bluetooth::hidHostConnect(addr);
|
||||
@@ -80,67 +83,48 @@ static void onForgetPeer(const std::array<uint8_t, 6>& addr) {
|
||||
bluetooth::unpair(addr);
|
||||
}
|
||||
|
||||
BtManage::BtManage() {
|
||||
bindings = (Bindings) {
|
||||
.onBtToggled = onBtToggled,
|
||||
.onScanToggled = onScanToggled,
|
||||
.onConnectPeer = onConnectPeer,
|
||||
.onDisconnectPeer = onDisconnectPeer,
|
||||
.onPairPeer = onPairPeer,
|
||||
.onForgetPeer = onForgetPeer,
|
||||
};
|
||||
}
|
||||
static void onKernelBtEvent(Device* /*device*/, void* context, BtEvent event);
|
||||
|
||||
void BtManage::lock() {
|
||||
mutex.lock();
|
||||
}
|
||||
|
||||
void BtManage::unlock() {
|
||||
mutex.unlock();
|
||||
}
|
||||
|
||||
void BtManage::requestViewUpdate() {
|
||||
// Lock order must match onShow()/onHide(): both run under GuiService's lvgl_lock()
|
||||
// and then take `mutex` internally. Taking `mutex` before lvgl_lock() here would
|
||||
// invert that order and deadlock against a concurrent onHide()/onShow() (GUI task
|
||||
// holding LVGL lock, waiting on `mutex`; this task holding `mutex`, waiting on LVGL
|
||||
// lock) - exactly what happens when BT events fire rapidly (e.g. during scanning)
|
||||
// while the app is being hidden.
|
||||
void requestViewUpdate(Context* ctx) {
|
||||
// Lock order must match appMain()'s setup/teardown: both run under the LVGL lock
|
||||
// and then take `ctx->mutex` internally. Taking `mutex` before lvgl_lock() here would
|
||||
// invert that order and deadlock against a concurrent teardown (GUI task holding the
|
||||
// LVGL lock, waiting on `mutex`; this task holding `mutex`, waiting on the LVGL lock) -
|
||||
// exactly what happens when BT events fire rapidly (e.g. during scanning) while the app
|
||||
// is closing.
|
||||
lvgl_lock();
|
||||
lock();
|
||||
if (isViewEnabled) {
|
||||
view.update();
|
||||
}
|
||||
unlock();
|
||||
ctx->lock();
|
||||
ctx->view.update();
|
||||
ctx->unlock();
|
||||
lvgl_unlock();
|
||||
}
|
||||
|
||||
void BtManage::onBtEvent(const BtEvent& event) {
|
||||
void onBtEvent(Context* ctx, const BtEvent& event) {
|
||||
auto radio_state = bluetooth::getRadioState();
|
||||
LOG_I(TAG, "Update with state %s", bluetooth::radioStateToString(radio_state));
|
||||
getState().setRadioState(radio_state);
|
||||
ctx->state.setRadioState(radio_state);
|
||||
switch (event.type) {
|
||||
case BT_EVENT_SCAN_STARTED:
|
||||
getState().setScanning(true);
|
||||
ctx->state.setScanning(true);
|
||||
break;
|
||||
case BT_EVENT_SCAN_FINISHED:
|
||||
getState().setScanning(false);
|
||||
getState().updateScanResults();
|
||||
getState().updatePairedPeers();
|
||||
ctx->state.setScanning(false);
|
||||
ctx->state.updateScanResults();
|
||||
ctx->state.updatePairedPeers();
|
||||
break;
|
||||
case BT_EVENT_PEER_FOUND:
|
||||
getState().updateScanResults();
|
||||
ctx->state.updateScanResults();
|
||||
break;
|
||||
case BT_EVENT_PAIR_RESULT:
|
||||
getState().updatePairedPeers();
|
||||
ctx->state.updatePairedPeers();
|
||||
break;
|
||||
case BT_EVENT_PROFILE_STATE_CHANGED:
|
||||
getState().updateScanResults();
|
||||
getState().updatePairedPeers();
|
||||
ctx->state.updateScanResults();
|
||||
ctx->state.updatePairedPeers();
|
||||
break;
|
||||
case BT_EVENT_RADIO_STATE_CHANGED:
|
||||
if (event.radio_state == BT_RADIO_STATE_ON) {
|
||||
getState().updatePairedPeers();
|
||||
ctx->state.updatePairedPeers();
|
||||
Device* dev = nullptr;
|
||||
if (device_get_first_active_by_type(&BLUETOOTH_TYPE, &dev) == ERROR_NONE && !bluetooth_is_scanning(dev)) {
|
||||
bluetooth_scan_start(dev);
|
||||
@@ -154,7 +138,7 @@ void BtManage::onBtEvent(const BtEvent& event) {
|
||||
break;
|
||||
}
|
||||
|
||||
requestViewUpdate();
|
||||
requestViewUpdate(ctx);
|
||||
}
|
||||
|
||||
static void onKernelBtEvent(Device* /*device*/, void* context, BtEvent event) {
|
||||
@@ -163,65 +147,88 @@ static void onKernelBtEvent(Device* /*device*/, void* context, BtEvent event) {
|
||||
// task would block it on the LVGL mutex (held by the LVGL task waiting in
|
||||
// nimble_port_stop), creating a permanent deadlock. Dispatch to the main task so
|
||||
// the NimBLE host task is never blocked by BtManage's state updates or LVGL lock.
|
||||
auto* self = static_cast<BtManage*>(context);
|
||||
// Captured while `self` is still guaranteed valid (the callback is only invoked
|
||||
// while registered, i.e. before onHide() removes it). Comparing this later - without
|
||||
// dereferencing `self` - lets the dispatched lambda detect a stale event from a
|
||||
// session that has since been hidden (and possibly destroyed) without a UAF.
|
||||
auto generation = self->getGeneration();
|
||||
auto* ctx = static_cast<Context*>(context);
|
||||
// Captured while `ctx` is still guaranteed valid (the callback is only invoked while
|
||||
// registered, i.e. before appMain()'s cleanup removes it). Comparing this later -
|
||||
// without dereferencing `ctx` - lets the dispatched lambda detect a stale event from an
|
||||
// instance that has since closed (and had its Context destroyed) without a UAF: the
|
||||
// generation bump in appMain()'s cleanup always happens before window_manager_remove()
|
||||
// destroys ctx's widgets, and this dispatched lambda always re-reads the live generation
|
||||
// at run time (not at dispatch time), so a bump landing anywhere before this lambda
|
||||
// actually runs is enough to make it skip touching ctx.
|
||||
auto generation = ctx->generation;
|
||||
int expectedGeneration = generation->load();
|
||||
getMainDispatcher().dispatch([self, generation, expectedGeneration, event] {
|
||||
getMainDispatcher().dispatch([ctx, generation, expectedGeneration, event] {
|
||||
if (generation->load() != expectedGeneration) {
|
||||
return;
|
||||
}
|
||||
self->onBtEvent(event);
|
||||
onBtEvent(ctx, event);
|
||||
});
|
||||
}
|
||||
|
||||
void BtManage::registerDeviceCallback(Device* dev) {
|
||||
lock();
|
||||
if (btDevice == dev && !callbackRegistered) {
|
||||
void registerDeviceCallback(Context* ctx, Device* dev) {
|
||||
ctx->lock();
|
||||
if (ctx->btDevice == dev && !ctx->callbackRegistered) {
|
||||
// Only latch the flag on success: while the radio is off the driver has no
|
||||
// callback list yet, so this add is a silent no-op and must be retried once
|
||||
// bluetooth::start() actually brings the device up.
|
||||
if (bluetooth_add_event_callback(dev, this, onKernelBtEvent) == ERROR_NONE) {
|
||||
callbackRegistered = true;
|
||||
if (bluetooth_add_event_callback(dev, ctx, onKernelBtEvent) == ERROR_NONE) {
|
||||
ctx->callbackRegistered = true;
|
||||
}
|
||||
}
|
||||
unlock();
|
||||
ctx->unlock();
|
||||
}
|
||||
|
||||
void BtManage::forgetCallbackRegistration() {
|
||||
lock();
|
||||
callbackRegistered = false;
|
||||
unlock();
|
||||
void forgetCallbackRegistration(Context* ctx) {
|
||||
ctx->lock();
|
||||
ctx->callbackRegistered = false;
|
||||
ctx->unlock();
|
||||
}
|
||||
|
||||
void BtManage::onShow(AppContext& app, lv_obj_t* parent) {
|
||||
// Initialise state and view before subscribing to avoid incoming events
|
||||
// racing with state initialisation.
|
||||
state.setRadioState(bluetooth::getRadioState());
|
||||
void onBackPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(ctx->appInstanceId, &closeEvent);
|
||||
}
|
||||
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
ctx->lock();
|
||||
ctx->view.init(ctx, parent);
|
||||
ctx->view.update();
|
||||
ctx->unlock();
|
||||
}
|
||||
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
Context ctx;
|
||||
ctx.appInstanceId = appInstanceId;
|
||||
ctx.bindings = (Bindings) {
|
||||
.onBtToggled = onBtToggled,
|
||||
.onScanToggled = onScanToggled,
|
||||
.onConnectPeer = onConnectPeer,
|
||||
.onDisconnectPeer = onDisconnectPeer,
|
||||
.onPairPeer = onPairPeer,
|
||||
.onForgetPeer = onForgetPeer,
|
||||
};
|
||||
|
||||
// Initialise state before subscribing to avoid incoming events racing with it.
|
||||
ctx.state.setRadioState(bluetooth::getRadioState());
|
||||
Device* dev = nullptr;
|
||||
device_get_first_by_type(&BLUETOOTH_TYPE, &dev);
|
||||
|
||||
state.setScanning(dev ? bluetooth_is_scanning(dev) : false);
|
||||
state.updateScanResults();
|
||||
state.updatePairedPeers();
|
||||
ctx.state.setScanning(dev ? bluetooth_is_scanning(dev) : false);
|
||||
ctx.state.updateScanResults();
|
||||
ctx.state.updatePairedPeers();
|
||||
|
||||
lock();
|
||||
isViewEnabled = true;
|
||||
view.init(app, parent);
|
||||
view.update();
|
||||
unlock();
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
if (btDevice) {
|
||||
// Decrease refcount before re-ssignment
|
||||
device_put(btDevice);
|
||||
}
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
|
||||
btDevice = dev;
|
||||
if (btDevice) {
|
||||
registerDeviceCallback(btDevice);
|
||||
ctx.btDevice = dev;
|
||||
if (ctx.btDevice) {
|
||||
registerDeviceCallback(&ctx, ctx.btDevice);
|
||||
}
|
||||
|
||||
auto radio_state = bluetooth::getRadioState();
|
||||
@@ -233,37 +240,53 @@ void BtManage::onShow(AppContext& app, lv_obj_t* parent) {
|
||||
if (can_scan && dev && !bluetooth_is_scanning(dev)) {
|
||||
bluetooth_scan_start(dev);
|
||||
}
|
||||
}
|
||||
|
||||
void BtManage::onHide(AppContext& app) {
|
||||
// Invalidate any BT event dispatched-but-not-yet-run for this session before doing
|
||||
// anything else, so it can't race a subsequent destruction of this instance (see
|
||||
// onKernelBtEvent()/getGeneration()).
|
||||
generation->fetch_add(1);
|
||||
|
||||
lock();
|
||||
if (btDevice) {
|
||||
if (callbackRegistered) {
|
||||
bluetooth_remove_event_callback(btDevice, onKernelBtEvent);
|
||||
callbackRegistered = false;
|
||||
bool shouldClose = false;
|
||||
while (!shouldClose) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
switch (event.type) {
|
||||
case APP_EVENT_CLOSE:
|
||||
app_manager_finish(appInstanceId);
|
||||
shouldClose = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
device_put(btDevice);
|
||||
btDevice = nullptr;
|
||||
}
|
||||
isViewEnabled = false;
|
||||
unlock();
|
||||
|
||||
// Invalidate any BT event dispatched-but-not-yet-run for this instance before doing
|
||||
// anything else, so it can't race the teardown below (see onKernelBtEvent()).
|
||||
ctx.generation->fetch_add(1);
|
||||
|
||||
if (ctx.btDevice) {
|
||||
if (ctx.callbackRegistered) {
|
||||
bluetooth_remove_event_callback(ctx.btDevice, onKernelBtEvent);
|
||||
ctx.callbackRegistered = false;
|
||||
}
|
||||
device_put(ctx.btDevice);
|
||||
ctx.btDevice = nullptr;
|
||||
}
|
||||
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "BtManage",
|
||||
.appName = "Bluetooth",
|
||||
.appIcon = LVGL_ICON_SHARED_BLUETOOTH,
|
||||
.appCategory = Category::Settings,
|
||||
.createApp = create<BtManage>
|
||||
uint32_t start() {
|
||||
uint32_t instanceId = 0;
|
||||
app_manager_start(manifest.id, &instanceId);
|
||||
return instanceId;
|
||||
}
|
||||
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "BtManage",
|
||||
.name = "Bluetooth",
|
||||
.category = APP_CATEGORY_SETTINGS,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
|
||||
};
|
||||
|
||||
LaunchId start() {
|
||||
return app::start(manifest.appId);
|
||||
}
|
||||
|
||||
} // namespace tt::app::btmanage
|
||||
|
||||
@@ -13,13 +13,26 @@
|
||||
#include <Tactility/bluetooth/BluetoothPairedDevice.h>
|
||||
#include <Tactility/Tactility.h>
|
||||
|
||||
#include <app/event.h>
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
|
||||
namespace tt::app::btmanage {
|
||||
|
||||
static void onBackPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
|
||||
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
|
||||
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
|
||||
// deadlock against itself.
|
||||
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(ctx->appInstanceId, &closeEvent);
|
||||
}
|
||||
|
||||
static void onEnableSwitchChanged(lv_event_t* event) {
|
||||
auto* enable_switch = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
bool is_on = lv_obj_has_state(enable_switch, LV_STATE_CHECKED);
|
||||
auto bt = std::static_pointer_cast<BtManage>(getCurrentApp());
|
||||
bt->getBindings().onBtToggled(is_on);
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
ctx->bindings.onBtToggled(ctx, is_on);
|
||||
}
|
||||
|
||||
static void onEnableOnBootSwitchChanged(lv_event_t* event) {
|
||||
@@ -45,39 +58,40 @@ static void onEnableOnBootParentClicked(lv_event_t* event) {
|
||||
}
|
||||
|
||||
static void onScanButtonClicked(lv_event_t* event) {
|
||||
auto bt = std::static_pointer_cast<BtManage>(getCurrentApp());
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
Device* dev = nullptr;
|
||||
device_get_first_active_by_type(&BLUETOOTH_TYPE, &dev);
|
||||
bool scanning = dev ? bluetooth_is_scanning(dev) : false;
|
||||
if (dev) {
|
||||
device_put(dev);
|
||||
}
|
||||
bt->getBindings().onScanToggled(!scanning);
|
||||
ctx->bindings.onScanToggled(ctx, !scanning);
|
||||
}
|
||||
|
||||
// region Peer list callbacks
|
||||
|
||||
struct PeerListItemData {
|
||||
void* context;
|
||||
State* state;
|
||||
Bindings* bindings;
|
||||
size_t index;
|
||||
bool isPaired;
|
||||
};
|
||||
|
||||
void View::onConnect(lv_event_t* event) {
|
||||
auto* data = static_cast<PeerListItemData*>(lv_event_get_user_data(event));
|
||||
auto bt = std::static_pointer_cast<BtManage>(getCurrentApp());
|
||||
auto& state = bt->getState();
|
||||
|
||||
if (data->isPaired) {
|
||||
// Open the per-device settings screen for paired devices
|
||||
auto peers = state.getPairedPeers();
|
||||
auto peers = data->state->getPairedPeers();
|
||||
if (data->index < peers.size()) {
|
||||
btpeersettings::start(bluetooth::settings::addrToHex(peers[data->index].addr));
|
||||
}
|
||||
} else {
|
||||
// Unrecognised scan result — initiate pairing
|
||||
auto peers = state.getScanResults();
|
||||
auto peers = data->state->getScanResults();
|
||||
if (data->index < peers.size()) {
|
||||
bt->getBindings().onPairPeer(peers[data->index].addr);
|
||||
data->bindings->onPairPeer(data->context, peers[data->index].addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,7 +116,7 @@ void View::createPeerListItem(const bluetooth::PeerRecord& record, bool isPaired
|
||||
|
||||
auto* button = lv_list_add_button(peers_list, nullptr, label.c_str());
|
||||
|
||||
auto* item_data = new PeerListItemData { index, isPaired };
|
||||
auto* item_data = new PeerListItemData { context, state, bindings, index, isPaired };
|
||||
lv_obj_set_user_data(button, item_data);
|
||||
lv_obj_add_event_cb(button, onConnect, LV_EVENT_SHORT_CLICKED, item_data);
|
||||
lv_obj_add_event_cb(button, [](lv_event_t* e) {
|
||||
@@ -210,26 +224,28 @@ void View::updatePeerList() {
|
||||
lv_obj_set_style_margin_ver(scan_button, 4, LV_STATE_DEFAULT);
|
||||
auto* scan_label = lv_label_create(scan_button);
|
||||
lv_label_set_text(scan_label, state->isScanning() ? "Stop scan" : "Scan");
|
||||
lv_obj_add_event_cb(scan_button, onScanButtonClicked, LV_EVENT_SHORT_CLICKED, nullptr);
|
||||
lv_obj_add_event_cb(scan_button, onScanButtonClicked, LV_EVENT_SHORT_CLICKED, context);
|
||||
}
|
||||
}
|
||||
|
||||
// endregion Secondary updates
|
||||
|
||||
void View::init(const AppContext& app, lv_obj_t* parent) {
|
||||
void View::init(void* newContext, lv_obj_t* parent) {
|
||||
context = newContext;
|
||||
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
root = parent;
|
||||
paths = app.getPaths();
|
||||
|
||||
// Toolbar
|
||||
auto* toolbar = lvgl::toolbar_create(parent, app);
|
||||
auto* toolbar = lvgl_toolbar_create(parent, "Bluetooth");
|
||||
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, context);
|
||||
|
||||
scanning_spinner = lvgl_toolbar_add_spinner_action(toolbar);
|
||||
|
||||
enable_switch = lvgl_toolbar_add_switch_action(toolbar);
|
||||
lv_obj_add_event_cb(enable_switch, onEnableSwitchChanged, LV_EVENT_VALUE_CHANGED, nullptr);
|
||||
lv_obj_add_event_cb(enable_switch, onEnableSwitchChanged, LV_EVENT_VALUE_CHANGED, context);
|
||||
|
||||
// Peer list
|
||||
peers_list = lv_list_create(parent);
|
||||
|
||||
@@ -3,15 +3,17 @@
|
||||
#include <lvgl/lvgl.h>
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
|
||||
#include <Tactility/app/App.h>
|
||||
#include <Tactility/app/AppContext.h>
|
||||
#include <Tactility/app/AppManifest.h>
|
||||
#include <Tactility/app/alertdialog/AlertDialog.h>
|
||||
#include <Tactility/bluetooth/Bluetooth.h>
|
||||
#include <Tactility/bluetooth/BluetoothPairedDevice.h>
|
||||
#include <Tactility/lvgl/Style.h>
|
||||
|
||||
#include <tactility/check.h>
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/bluetooth.h>
|
||||
#include <tactility/log.h>
|
||||
@@ -20,211 +22,245 @@ namespace tt::app::btpeersettings {
|
||||
|
||||
constexpr auto* TAG = "BtPeerSettings";
|
||||
|
||||
extern const AppManifest manifest;
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
void start(const std::string& addrHex) {
|
||||
auto bundle = std::make_shared<Bundle>();
|
||||
bundle->putString("addr", addrHex);
|
||||
app::start(manifest.appId, bundle);
|
||||
}
|
||||
namespace {
|
||||
|
||||
class BtPeerSettings : public App {
|
||||
|
||||
bool viewEnabled = false;
|
||||
lv_obj_t* connectButton = nullptr;
|
||||
lv_obj_t* disconnectButton = nullptr;
|
||||
struct Context {
|
||||
uint32_t appInstanceId;
|
||||
std::string addrHex;
|
||||
std::array<uint8_t, 6> addr = {};
|
||||
int profileId = BT_PROFILE_HID_HOST;
|
||||
bool isCurrentlyConnected() const {
|
||||
for (const auto& p : bluetooth::getPairedPeers()) {
|
||||
if (p.addr == addr) return p.connected;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static void onPressConnect(lv_event_t* event) {
|
||||
auto* self = static_cast<BtPeerSettings*>(lv_event_get_user_data(event));
|
||||
if (self->profileId == BT_PROFILE_HID_HOST) {
|
||||
bluetooth::hidHostConnect(self->addr);
|
||||
} else {
|
||||
bluetooth::connect(self->addr, self->profileId);
|
||||
}
|
||||
lv_obj_add_state(lv_event_get_target_obj(event), LV_STATE_DISABLED);
|
||||
}
|
||||
|
||||
static void onPressDisconnect(lv_event_t* event) {
|
||||
auto* self = static_cast<BtPeerSettings*>(lv_event_get_user_data(event));
|
||||
if (self->profileId == BT_PROFILE_HID_HOST) {
|
||||
bluetooth::hidHostDisconnect();
|
||||
} else {
|
||||
bluetooth::disconnect(self->addr, self->profileId);
|
||||
}
|
||||
lv_obj_add_state(lv_event_get_target_obj(event), LV_STATE_DISABLED);
|
||||
}
|
||||
|
||||
static void onPressForget(lv_event_t* event) {
|
||||
std::vector<std::string> choices = { "Yes", "No" };
|
||||
alertdialog::start("Confirmation", "Forget this device?", choices);
|
||||
}
|
||||
|
||||
static void onToggleAutoConnect(lv_event_t* event) {
|
||||
auto* self = static_cast<BtPeerSettings*>(lv_event_get_user_data(event));
|
||||
bool is_on = lv_obj_has_state(lv_event_get_target_obj(event), LV_STATE_CHECKED);
|
||||
bluetooth::settings::PairedDevice device;
|
||||
if (bluetooth::settings::load(self->addrHex, device)) {
|
||||
device.autoConnect = is_on;
|
||||
if (!bluetooth::settings::save(device)) {
|
||||
LOG_E(TAG, "Failed to save auto-connect setting");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void requestViewUpdate() const {
|
||||
if (viewEnabled) {
|
||||
lvgl_lock();
|
||||
updateViews();
|
||||
lvgl_unlock();
|
||||
}
|
||||
}
|
||||
|
||||
void updateViews() const {
|
||||
if (isCurrentlyConnected()) {
|
||||
lv_obj_remove_flag(disconnectButton, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_add_flag(connectButton, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_remove_state(disconnectButton, LV_STATE_DISABLED);
|
||||
} else {
|
||||
lv_obj_add_flag(disconnectButton, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_remove_flag(connectButton, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_remove_state(connectButton, LV_STATE_DISABLED);
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
|
||||
void onCreate(AppContext& app) override {
|
||||
const auto parameters = app.getParameters();
|
||||
check(parameters != nullptr, "Parameters missing");
|
||||
addrHex = parameters->getString("addr");
|
||||
|
||||
// Load addr and profileId from stored settings — avoids manual hex parsing
|
||||
// (std::stoul throws on invalid input and exceptions are disabled).
|
||||
bluetooth::settings::PairedDevice device;
|
||||
if (bluetooth::settings::load(addrHex, device)) {
|
||||
addr = device.addr;
|
||||
profileId = device.profileId;
|
||||
}
|
||||
}
|
||||
|
||||
static void onKernelBtEvent(struct Device* /*device*/, void* context, struct BtEvent /*event*/) {
|
||||
static_cast<BtPeerSettings*>(context)->requestViewUpdate();
|
||||
}
|
||||
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override {
|
||||
{
|
||||
Device* dev;
|
||||
if (device_get_first_active_by_type(&BLUETOOTH_TYPE, &dev) == ERROR_NONE) {
|
||||
bluetooth_add_event_callback(dev, this, onKernelBtEvent);
|
||||
device_put(dev);
|
||||
}
|
||||
}
|
||||
|
||||
// Load stored settings (name, autoConnect)
|
||||
bluetooth::settings::PairedDevice device;
|
||||
bool deviceLoaded = bluetooth::settings::load(addrHex, device);
|
||||
std::string title = (deviceLoaded && !device.name.empty()) ? device.name : addrHex;
|
||||
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
lvgl_toolbar_create(parent, title.c_str());
|
||||
|
||||
auto* wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_width(wrapper, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(wrapper, 1);
|
||||
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT);
|
||||
lvgl::obj_set_style_bg_invisible(wrapper);
|
||||
|
||||
connectButton = lv_button_create(wrapper);
|
||||
lv_obj_set_width(connectButton, LV_PCT(100));
|
||||
lv_obj_add_event_cb(connectButton, onPressConnect, LV_EVENT_SHORT_CLICKED, this);
|
||||
auto* connect_label = lv_label_create(connectButton);
|
||||
lv_obj_align(connect_label, LV_ALIGN_CENTER, 0, 0);
|
||||
lv_label_set_text(connect_label, "Connect");
|
||||
|
||||
disconnectButton = lv_button_create(wrapper);
|
||||
lv_obj_set_width(disconnectButton, LV_PCT(100));
|
||||
lv_obj_add_event_cb(disconnectButton, onPressDisconnect, LV_EVENT_SHORT_CLICKED, this);
|
||||
auto* disconnect_label = lv_label_create(disconnectButton);
|
||||
lv_obj_align(disconnect_label, LV_ALIGN_CENTER, 0, 0);
|
||||
lv_label_set_text(disconnect_label, "Disconnect");
|
||||
|
||||
auto* forget_button = lv_button_create(wrapper);
|
||||
lv_obj_set_width(forget_button, LV_PCT(100));
|
||||
lv_obj_add_event_cb(forget_button, onPressForget, LV_EVENT_SHORT_CLICKED, this);
|
||||
auto* forget_label = lv_label_create(forget_button);
|
||||
lv_obj_align(forget_label, LV_ALIGN_CENTER, 0, 0);
|
||||
lv_label_set_text(forget_label, "Forget");
|
||||
|
||||
// Auto-connect toggle row
|
||||
auto* auto_connect_wrapper = lv_obj_create(wrapper);
|
||||
lv_obj_set_size(auto_connect_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lvgl::obj_set_style_bg_invisible(auto_connect_wrapper);
|
||||
lv_obj_set_style_pad_all(auto_connect_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(auto_connect_wrapper, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* auto_connect_label = lv_label_create(auto_connect_wrapper);
|
||||
lv_label_set_text(auto_connect_label, "Auto-connect");
|
||||
lv_obj_align(auto_connect_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
|
||||
auto* auto_connect_switch = lv_switch_create(auto_connect_wrapper);
|
||||
lv_obj_add_event_cb(auto_connect_switch, onToggleAutoConnect, LV_EVENT_VALUE_CHANGED, this);
|
||||
lv_obj_align(auto_connect_switch, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
|
||||
if (deviceLoaded && device.autoConnect) {
|
||||
lv_obj_add_state(auto_connect_switch, LV_STATE_CHECKED);
|
||||
} else {
|
||||
lv_obj_remove_state(auto_connect_switch, LV_STATE_CHECKED);
|
||||
}
|
||||
|
||||
viewEnabled = true;
|
||||
updateViews();
|
||||
}
|
||||
|
||||
void onHide(AppContext& app) override {
|
||||
Device* dev;
|
||||
if (device_get_first_active_by_type(&BLUETOOTH_TYPE, &dev) == ERROR_NONE) {
|
||||
bluetooth_remove_event_callback(dev, onKernelBtEvent);
|
||||
device_put(dev);
|
||||
}
|
||||
viewEnabled = false;
|
||||
}
|
||||
|
||||
void onResult(AppContext& appContext, LaunchId /*launchId*/, Result result, std::unique_ptr<Bundle> bundle) override {
|
||||
if (result != Result::Ok || bundle == nullptr) return;
|
||||
if (alertdialog::getResultIndex(*bundle) != 0) return; // 0 = Yes
|
||||
|
||||
// Disconnect first if connected
|
||||
if (isCurrentlyConnected()) {
|
||||
if (profileId == BT_PROFILE_HID_HOST) {
|
||||
bluetooth::hidHostDisconnect();
|
||||
} else {
|
||||
bluetooth::disconnect(addr, profileId);
|
||||
}
|
||||
}
|
||||
|
||||
bluetooth::unpair(addr);
|
||||
stop();
|
||||
}
|
||||
lv_obj_t* connectButton = nullptr;
|
||||
lv_obj_t* disconnectButton = nullptr;
|
||||
};
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "BtPeerSettings",
|
||||
.appName = "BT Device Settings",
|
||||
.appCategory = Category::System,
|
||||
.appFlags = AppManifest::Flags::Hidden,
|
||||
.createApp = create<BtPeerSettings>
|
||||
|
||||
bool isCurrentlyConnected(const Context* ctx) {
|
||||
for (const auto& p : bluetooth::getPairedPeers()) {
|
||||
if (p.addr == ctx->addr) return p.connected;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void updateViews(const Context* ctx) {
|
||||
if (isCurrentlyConnected(ctx)) {
|
||||
lv_obj_remove_flag(ctx->disconnectButton, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_add_flag(ctx->connectButton, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_remove_state(ctx->disconnectButton, LV_STATE_DISABLED);
|
||||
} else {
|
||||
lv_obj_add_flag(ctx->disconnectButton, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_remove_flag(ctx->connectButton, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_remove_state(ctx->connectButton, LV_STATE_DISABLED);
|
||||
}
|
||||
}
|
||||
|
||||
void onKernelBtEvent(struct Device* /*device*/, void* context, struct BtEvent /*event*/) {
|
||||
auto* ctx = static_cast<Context*>(context);
|
||||
lvgl_lock();
|
||||
updateViews(ctx);
|
||||
lvgl_unlock();
|
||||
}
|
||||
|
||||
void onPressConnect(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
if (ctx->profileId == BT_PROFILE_HID_HOST) {
|
||||
bluetooth::hidHostConnect(ctx->addr);
|
||||
} else {
|
||||
bluetooth::connect(ctx->addr, ctx->profileId);
|
||||
}
|
||||
lv_obj_add_state(lv_event_get_target_obj(event), LV_STATE_DISABLED);
|
||||
}
|
||||
|
||||
void onPressDisconnect(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
if (ctx->profileId == BT_PROFILE_HID_HOST) {
|
||||
bluetooth::hidHostDisconnect();
|
||||
} else {
|
||||
bluetooth::disconnect(ctx->addr, ctx->profileId);
|
||||
}
|
||||
lv_obj_add_state(lv_event_get_target_obj(event), LV_STATE_DISABLED);
|
||||
}
|
||||
|
||||
void onPressForget(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
// Result isn't tracked by launch id (matches the original's behavior) - this app only
|
||||
// ever has one dialog in flight at a time.
|
||||
alertdialog::start(ctx->appInstanceId, "Confirmation", "Forget this device?", std::vector<std::string> { "Yes", "No" });
|
||||
}
|
||||
|
||||
void onBackPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
|
||||
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
|
||||
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
|
||||
// deadlock against itself.
|
||||
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(ctx->appInstanceId, &closeEvent);
|
||||
}
|
||||
|
||||
void onToggleAutoConnect(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
bool is_on = lv_obj_has_state(lv_event_get_target_obj(event), LV_STATE_CHECKED);
|
||||
bluetooth::settings::PairedDevice device;
|
||||
if (bluetooth::settings::load(ctx->addrHex, device)) {
|
||||
device.autoConnect = is_on;
|
||||
if (!bluetooth::settings::save(device)) {
|
||||
LOG_E(TAG, "Failed to save auto-connect setting");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
|
||||
bluetooth::settings::PairedDevice device;
|
||||
bool deviceLoaded = bluetooth::settings::load(ctx->addrHex, device);
|
||||
std::string title = (deviceLoaded && !device.name.empty()) ? device.name : ctx->addrHex;
|
||||
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* toolbar = lvgl_toolbar_create(parent, title.c_str());
|
||||
// The global toolbar nav callback only knows how to stop old-model apps.
|
||||
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
|
||||
|
||||
auto* wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_width(wrapper, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(wrapper, 1);
|
||||
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT);
|
||||
lvgl::obj_set_style_bg_invisible(wrapper);
|
||||
|
||||
ctx->connectButton = lv_button_create(wrapper);
|
||||
lv_obj_set_width(ctx->connectButton, LV_PCT(100));
|
||||
lv_obj_add_event_cb(ctx->connectButton, onPressConnect, LV_EVENT_SHORT_CLICKED, ctx);
|
||||
auto* connect_label = lv_label_create(ctx->connectButton);
|
||||
lv_obj_align(connect_label, LV_ALIGN_CENTER, 0, 0);
|
||||
lv_label_set_text(connect_label, "Connect");
|
||||
|
||||
ctx->disconnectButton = lv_button_create(wrapper);
|
||||
lv_obj_set_width(ctx->disconnectButton, LV_PCT(100));
|
||||
lv_obj_add_event_cb(ctx->disconnectButton, onPressDisconnect, LV_EVENT_SHORT_CLICKED, ctx);
|
||||
auto* disconnect_label = lv_label_create(ctx->disconnectButton);
|
||||
lv_obj_align(disconnect_label, LV_ALIGN_CENTER, 0, 0);
|
||||
lv_label_set_text(disconnect_label, "Disconnect");
|
||||
|
||||
auto* forget_button = lv_button_create(wrapper);
|
||||
lv_obj_set_width(forget_button, LV_PCT(100));
|
||||
lv_obj_add_event_cb(forget_button, onPressForget, LV_EVENT_SHORT_CLICKED, ctx);
|
||||
auto* forget_label = lv_label_create(forget_button);
|
||||
lv_obj_align(forget_label, LV_ALIGN_CENTER, 0, 0);
|
||||
lv_label_set_text(forget_label, "Forget");
|
||||
|
||||
// Auto-connect toggle row
|
||||
auto* auto_connect_wrapper = lv_obj_create(wrapper);
|
||||
lv_obj_set_size(auto_connect_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lvgl::obj_set_style_bg_invisible(auto_connect_wrapper);
|
||||
lv_obj_set_style_pad_all(auto_connect_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(auto_connect_wrapper, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* auto_connect_label = lv_label_create(auto_connect_wrapper);
|
||||
lv_label_set_text(auto_connect_label, "Auto-connect");
|
||||
lv_obj_align(auto_connect_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
|
||||
auto* auto_connect_switch = lv_switch_create(auto_connect_wrapper);
|
||||
lv_obj_add_event_cb(auto_connect_switch, onToggleAutoConnect, LV_EVENT_VALUE_CHANGED, ctx);
|
||||
lv_obj_align(auto_connect_switch, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
|
||||
if (deviceLoaded && device.autoConnect) {
|
||||
lv_obj_add_state(auto_connect_switch, LV_STATE_CHECKED);
|
||||
} else {
|
||||
lv_obj_remove_state(auto_connect_switch, LV_STATE_CHECKED);
|
||||
}
|
||||
|
||||
updateViews(ctx);
|
||||
}
|
||||
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
|
||||
Context ctx {};
|
||||
ctx.appInstanceId = appInstanceId;
|
||||
ctx.addrHex = (argc > 0) ? argv[0] : std::string();
|
||||
|
||||
// Load addr and profileId from stored settings - avoids manual hex parsing (std::stoul
|
||||
// throws on invalid input and exceptions are disabled).
|
||||
bluetooth::settings::PairedDevice device;
|
||||
if (bluetooth::settings::load(ctx.addrHex, device)) {
|
||||
ctx.addr = device.addr;
|
||||
ctx.profileId = device.profileId;
|
||||
}
|
||||
|
||||
|
||||
Device* btDevice = nullptr;
|
||||
if (device_get_first_active_by_type(&BLUETOOTH_TYPE, &btDevice) == ERROR_NONE) {
|
||||
bluetooth_add_event_callback(btDevice, &ctx, onKernelBtEvent);
|
||||
device_put(btDevice);
|
||||
}
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
|
||||
bool shouldClose = false;
|
||||
while (!shouldClose) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
switch (event.type) {
|
||||
case APP_EVENT_CLOSE:
|
||||
app_manager_finish(appInstanceId);
|
||||
shouldClose = true;
|
||||
break;
|
||||
case APP_EVENT_RESULT:
|
||||
if (event.result.result == 0) { // 0 = Yes
|
||||
if (isCurrentlyConnected(&ctx)) {
|
||||
if (ctx.profileId == BT_PROFILE_HID_HOST) {
|
||||
bluetooth::hidHostDisconnect();
|
||||
} else {
|
||||
bluetooth::disconnect(ctx.addr, ctx.profileId);
|
||||
}
|
||||
}
|
||||
bluetooth::unpair(ctx.addr);
|
||||
app_manager_finish(appInstanceId);
|
||||
shouldClose = true;
|
||||
}
|
||||
app_manager_stop(event.result.launch_id);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (device_get_first_active_by_type(&BLUETOOTH_TYPE, &btDevice) == ERROR_NONE) {
|
||||
bluetooth_remove_event_callback(btDevice, onKernelBtEvent);
|
||||
device_put(btDevice);
|
||||
}
|
||||
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void start(const std::string& addrHex) {
|
||||
const char* argv[] = { addrHex.c_str() };
|
||||
uint32_t instanceId = 0;
|
||||
app_manager_start_with_parameters(manifest.id, 1, argv, &instanceId);
|
||||
}
|
||||
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "BtPeerSettings",
|
||||
.name = "BT Device Settings",
|
||||
.category = APP_CATEGORY_SYSTEM,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
|
||||
.flags = APP_MANIFEST_FLAG_HIDDEN,
|
||||
};
|
||||
|
||||
} // namespace tt::app::btpeersettings
|
||||
|
||||
@@ -6,11 +6,15 @@
|
||||
|
||||
#include <Tactility/app/chat/ChatAppPrivate.h>
|
||||
#include <Tactility/app/chat/ChatProtocol.h>
|
||||
#include <Tactility/app/AppManifest.h>
|
||||
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <tactility/log.h>
|
||||
|
||||
#include <lvgl/icons/shared.h>
|
||||
#include <lvgl/lvgl.h>
|
||||
|
||||
#include <algorithm>
|
||||
@@ -20,56 +24,34 @@
|
||||
|
||||
namespace tt::app::chat {
|
||||
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
constexpr auto* TAG = "ChatApp";
|
||||
static constexpr uint8_t BROADCAST_ADDRESS[ESP_NOW_ETH_ALEN] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF };
|
||||
|
||||
void ChatApp::enableEspNow() {
|
||||
void enableEspNow(Context* ctx) {
|
||||
static uint8_t defaultKey[ESP_NOW_KEY_LEN] = {};
|
||||
auto config = service::espnow::EspNowConfig(
|
||||
settings.hasEncryptionKey ? settings.encryptionKey.data() : defaultKey,
|
||||
ctx->settings.hasEncryptionKey ? ctx->settings.encryptionKey.data() : defaultKey,
|
||||
service::espnow::Mode::Station,
|
||||
1, // Channel 1 default; actual channel determined by WiFi if connected
|
||||
false,
|
||||
settings.hasEncryptionKey
|
||||
ctx->settings.hasEncryptionKey
|
||||
);
|
||||
service::espnow::enable(config);
|
||||
}
|
||||
|
||||
void ChatApp::disableEspNow() {
|
||||
void disableEspNow(Context* ctx) {
|
||||
(void)ctx;
|
||||
if (service::espnow::isEnabled()) {
|
||||
service::espnow::disable();
|
||||
}
|
||||
}
|
||||
|
||||
void ChatApp::onCreate(AppContext& appContext) {
|
||||
isFirstLaunch = !settingsFileExists();
|
||||
settings = loadSettings();
|
||||
state.setLocalNickname(settings.nickname);
|
||||
if (!settings.chatChannel.empty()) {
|
||||
state.setCurrentChannel(settings.chatChannel);
|
||||
}
|
||||
enableEspNow();
|
||||
namespace {
|
||||
|
||||
receiveSubscription = service::espnow::subscribeReceiver(
|
||||
[this](const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length) {
|
||||
onReceive(receiveInfo, data, length);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
void ChatApp::onDestroy(AppContext& appContext) {
|
||||
service::espnow::unsubscribeReceiver(receiveSubscription);
|
||||
disableEspNow();
|
||||
}
|
||||
|
||||
void ChatApp::onShow(AppContext& context, lv_obj_t* parent) {
|
||||
view.init(context, parent);
|
||||
if (isFirstLaunch) {
|
||||
view.showSettings(settings);
|
||||
}
|
||||
}
|
||||
|
||||
void ChatApp::onReceive(const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length) {
|
||||
void onReceive(Context* ctx, const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length) {
|
||||
if (length <= 0) return;
|
||||
|
||||
ParsedMessage parsed;
|
||||
@@ -82,21 +64,31 @@ void ChatApp::onReceive(const esp_now_recv_info_t* receiveInfo, const uint8_t* d
|
||||
msg.target = parsed.target;
|
||||
msg.isOwn = false;
|
||||
|
||||
state.addMessage(msg);
|
||||
ctx->state.addMessage(msg);
|
||||
|
||||
lvgl_lock();
|
||||
view.displayMessage(msg);
|
||||
ctx->view.displayMessage(msg);
|
||||
lvgl_unlock();
|
||||
}
|
||||
|
||||
void ChatApp::sendMessage(const std::string& text) {
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
ctx->view.init(parent);
|
||||
if (ctx->isFirstLaunch) {
|
||||
ctx->view.showSettings(ctx->settings);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void sendMessage(Context* ctx, const std::string& text) {
|
||||
if (text.empty()) return;
|
||||
|
||||
std::string nickname = state.getLocalNickname();
|
||||
std::string channel = state.getCurrentChannel();
|
||||
std::string nickname = ctx->state.getLocalNickname();
|
||||
std::string channel = ctx->state.getCurrentChannel();
|
||||
|
||||
std::vector<uint8_t> wireMsg;
|
||||
if (!serializeTextMessage(settings.senderId, BROADCAST_ID, nickname, channel, text, wireMsg)) {
|
||||
if (!serializeTextMessage(ctx->settings.senderId, BROADCAST_ID, nickname, channel, text, wireMsg)) {
|
||||
LOG_E(TAG, "Failed to serialize message");
|
||||
return;
|
||||
}
|
||||
@@ -111,18 +103,18 @@ void ChatApp::sendMessage(const std::string& text) {
|
||||
msg.target = channel;
|
||||
msg.isOwn = true;
|
||||
|
||||
state.addMessage(msg);
|
||||
ctx->state.addMessage(msg);
|
||||
|
||||
lvgl_lock();
|
||||
view.displayMessage(msg);
|
||||
ctx->view.displayMessage(msg);
|
||||
lvgl_unlock();
|
||||
}
|
||||
|
||||
void ChatApp::applySettings(const std::string& nickname, const std::string& keyHex) {
|
||||
void applySettings(Context* ctx, const std::string& nickname, const std::string& keyHex) {
|
||||
bool needRestart = false;
|
||||
|
||||
// Trim nickname to protocol limit
|
||||
settings.nickname = nickname.substr(0, MAX_NICKNAME_LEN);
|
||||
ctx->settings.nickname = nickname.substr(0, MAX_NICKNAME_LEN);
|
||||
|
||||
// Parse hex key
|
||||
if (keyHex.size() == ESP_NOW_KEY_LEN * 2) {
|
||||
@@ -134,50 +126,103 @@ void ChatApp::applySettings(const std::string& nickname, const std::string& keyH
|
||||
newKey[i] = static_cast<uint8_t>(strtoul(hex, nullptr, 16));
|
||||
}
|
||||
// Restart if key changed OR if encryption is being enabled
|
||||
bool wasEnabled = settings.hasEncryptionKey;
|
||||
if (!wasEnabled || !std::equal(newKey, newKey + ESP_NOW_KEY_LEN, settings.encryptionKey.begin())) {
|
||||
std::copy(newKey, newKey + ESP_NOW_KEY_LEN, settings.encryptionKey.begin());
|
||||
bool wasEnabled = ctx->settings.hasEncryptionKey;
|
||||
if (!wasEnabled || !std::equal(newKey, newKey + ESP_NOW_KEY_LEN, ctx->settings.encryptionKey.begin())) {
|
||||
std::copy(newKey, newKey + ESP_NOW_KEY_LEN, ctx->settings.encryptionKey.begin());
|
||||
needRestart = true;
|
||||
}
|
||||
settings.hasEncryptionKey = true;
|
||||
ctx->settings.hasEncryptionKey = true;
|
||||
} else {
|
||||
LOG_W(TAG, "Invalid hex characters in encryption key");
|
||||
}
|
||||
} else if (keyHex.empty()) {
|
||||
if (settings.hasEncryptionKey) {
|
||||
settings.encryptionKey.fill(0);
|
||||
settings.hasEncryptionKey = false;
|
||||
if (ctx->settings.hasEncryptionKey) {
|
||||
ctx->settings.encryptionKey.fill(0);
|
||||
ctx->settings.hasEncryptionKey = false;
|
||||
needRestart = true;
|
||||
}
|
||||
} else {
|
||||
LOG_W(TAG, "Key must be exactly %d hex characters, got %d", (int)(ESP_NOW_KEY_LEN * 2), (int)keyHex.size());
|
||||
}
|
||||
|
||||
state.setLocalNickname(settings.nickname);
|
||||
saveSettings(settings);
|
||||
ctx->state.setLocalNickname(ctx->settings.nickname);
|
||||
saveSettings(ctx->settings);
|
||||
|
||||
if (needRestart) {
|
||||
disableEspNow();
|
||||
enableEspNow();
|
||||
disableEspNow(ctx);
|
||||
enableEspNow(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
void ChatApp::switchChannel(const std::string& chatChannel) {
|
||||
void switchChannel(Context* ctx, const std::string& chatChannel) {
|
||||
const auto trimmedChannel = chatChannel.substr(0, MAX_TARGET_LEN);
|
||||
state.setCurrentChannel(trimmedChannel);
|
||||
settings.chatChannel = trimmedChannel;
|
||||
saveSettings(settings);
|
||||
ctx->state.setCurrentChannel(trimmedChannel);
|
||||
ctx->settings.chatChannel = trimmedChannel;
|
||||
saveSettings(ctx->settings);
|
||||
|
||||
lvgl_lock();
|
||||
view.refreshMessageList();
|
||||
ctx->view.refreshMessageList();
|
||||
lvgl_unlock();
|
||||
}
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "Chat",
|
||||
.appName = "Chat",
|
||||
.appIcon = LVGL_ICON_SHARED_FORUM,
|
||||
.createApp = create<ChatApp>
|
||||
namespace {
|
||||
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
Context ctx {};
|
||||
ctx.appInstanceId = appInstanceId;
|
||||
ctx.isFirstLaunch = !settingsFileExists();
|
||||
ctx.settings = loadSettings();
|
||||
ctx.state.setLocalNickname(ctx.settings.nickname);
|
||||
if (!ctx.settings.chatChannel.empty()) {
|
||||
ctx.state.setCurrentChannel(ctx.settings.chatChannel);
|
||||
}
|
||||
enableEspNow(&ctx);
|
||||
|
||||
ctx.receiveSubscription = service::espnow::subscribeReceiver(
|
||||
[&ctx](const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length) {
|
||||
onReceive(&ctx, receiveInfo, data, length);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
|
||||
bool shouldClose = false;
|
||||
while (!shouldClose) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
switch (event.type) {
|
||||
case APP_EVENT_CLOSE:
|
||||
app_manager_finish(appInstanceId);
|
||||
shouldClose = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
service::espnow::unsubscribeReceiver(ctx.receiveSubscription);
|
||||
disableEspNow(&ctx);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "Chat",
|
||||
.name = "Chat",
|
||||
.category = APP_CATEGORY_USER,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
|
||||
};
|
||||
|
||||
} // namespace tt::app::chat
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
#include <Tactility/app/chat/ChatSettings.h>
|
||||
#include <Tactility/app/chat/ChatProtocol.h>
|
||||
|
||||
#include <Tactility/DeprecatedPaths.h>
|
||||
#include <Tactility/file/File.h>
|
||||
#include <Tactility/file/PropertiesFile.h>
|
||||
#include <Tactility/Paths.h>
|
||||
|
||||
#include <crypt/crypt.h>
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
#include <Tactility/app/chat/ChatAppPrivate.h>
|
||||
#include <Tactility/app/chat/ChatProtocol.h>
|
||||
|
||||
#include <Tactility/lvgl/Toolbar.h>
|
||||
#include <app/event.h>
|
||||
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
@@ -144,11 +146,23 @@ void ChatView::createChannelPanel(lv_obj_t* parent) {
|
||||
lv_label_set_text(cancelLbl, "Cancel");
|
||||
}
|
||||
|
||||
void ChatView::init(AppContext& appContext, lv_obj_t* parent) {
|
||||
void ChatView::onBackPressed(lv_event_t* e) {
|
||||
auto* self = static_cast<ChatView*>(lv_event_get_user_data(e));
|
||||
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
|
||||
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
|
||||
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
|
||||
// deadlock against itself.
|
||||
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(self->app->appInstanceId, &closeEvent);
|
||||
}
|
||||
|
||||
void ChatView::init(lv_obj_t* parent) {
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
toolbar = lvgl::toolbar_create(parent, appContext);
|
||||
toolbar = lvgl_toolbar_create(parent, "Chat");
|
||||
// The global toolbar nav callback only knows how to stop old-model apps.
|
||||
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, this);
|
||||
lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_LIST, onChannelClicked, this);
|
||||
lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_SETTINGS, onSettingsClicked, this);
|
||||
updateToolbarTitle();
|
||||
@@ -245,14 +259,14 @@ void ChatView::onSendClicked(lv_event_t* e) {
|
||||
auto* self = static_cast<ChatView*>(lv_event_get_user_data(e));
|
||||
auto* text = lv_textarea_get_text(self->inputField);
|
||||
if (text && strlen(text) > 0) {
|
||||
self->app->sendMessage(std::string(text));
|
||||
sendMessage(self->app, std::string(text));
|
||||
lv_textarea_set_text(self->inputField, "");
|
||||
}
|
||||
}
|
||||
|
||||
void ChatView::onSettingsClicked(lv_event_t* e) {
|
||||
auto* self = static_cast<ChatView*>(lv_event_get_user_data(e));
|
||||
self->showSettings(self->app->getSettings());
|
||||
self->showSettings(self->app->settings);
|
||||
}
|
||||
|
||||
void ChatView::onSettingsSave(lv_event_t* e) {
|
||||
@@ -262,7 +276,8 @@ void ChatView::onSettingsSave(lv_event_t* e) {
|
||||
auto* keyHex = lv_textarea_get_text(self->keyInput);
|
||||
|
||||
if (nickname && strlen(nickname) > 0) {
|
||||
self->app->applySettings(
|
||||
applySettings(
|
||||
self->app,
|
||||
std::string(nickname),
|
||||
keyHex ? std::string(keyHex) : std::string()
|
||||
);
|
||||
@@ -284,7 +299,7 @@ void ChatView::onChannelSave(lv_event_t* e) {
|
||||
auto* self = static_cast<ChatView*>(lv_event_get_user_data(e));
|
||||
auto* text = lv_textarea_get_text(self->channelInput);
|
||||
if (text && strlen(text) > 0) {
|
||||
self->app->switchChannel(std::string(text));
|
||||
switchChannel(self->app, std::string(text));
|
||||
}
|
||||
self->hideChannelSelector();
|
||||
}
|
||||
|
||||
@@ -4,137 +4,208 @@
|
||||
#include <Tactility/app/crashdiagnostics/QrUrl.h>
|
||||
#include <Tactility/app/launcher/Launcher.h>
|
||||
#include <Tactility/lvgl/Statusbar.h>
|
||||
#include <Tactility/service/loader/Loader.h>
|
||||
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <lvgl.h>
|
||||
#include <qrcode.h>
|
||||
#include <tactility/drivers/pointer.h>
|
||||
#include <tactility/log.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace tt::app::crashdiagnostics {
|
||||
|
||||
constexpr auto* TAG = "CrashDiagnostics";
|
||||
|
||||
extern const AppManifest manifest;
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
namespace {
|
||||
|
||||
struct Context {
|
||||
uint32_t appInstanceId;
|
||||
// Set when widget creation hit an unrecoverable error (e.g. the QR code doesn't fit on
|
||||
// screen) - appMain() skips the event loop and closes immediately without ever starting
|
||||
// the launcher, matching the old model's stop()-without-launcher-start() error paths.
|
||||
bool hasFatalError = false;
|
||||
// Set by onContinuePressed() right before it emits APP_EVENT_CLOSE - read by appMain()
|
||||
// after its own thread finishes cleanup, to decide whether to start the launcher
|
||||
// afterwards (matches the old model's onContinuePressed(): stop() then launcher::start()).
|
||||
bool continuePressed = false;
|
||||
};
|
||||
|
||||
|
||||
void onContinuePressed(lv_event_t* event) {
|
||||
stop(manifest.appId);
|
||||
launcher::start();
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
ctx->continuePressed = true;
|
||||
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
|
||||
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
|
||||
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
|
||||
// deadlock against itself. launcher::start() is deferred to appMain(), after this app's
|
||||
// own thread has finished cleaning up.
|
||||
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(ctx->appInstanceId, &closeEvent);
|
||||
}
|
||||
|
||||
class CrashDiagnosticsApp : public App {
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
|
||||
public:
|
||||
auto* display = lv_obj_get_display(parent);
|
||||
int32_t parent_height = lv_display_get_vertical_resolution(display) - lvgl::statusbar_get_height();
|
||||
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override {
|
||||
auto* display = lv_obj_get_display(parent);
|
||||
int32_t parent_height = lv_display_get_vertical_resolution(display) - lvgl::statusbar_get_height();
|
||||
lv_obj_add_event_cb(parent, onContinuePressed, LV_EVENT_SHORT_CLICKED, ctx);
|
||||
auto* top_label = lv_label_create(parent);
|
||||
lv_label_set_text(top_label, "Oops! We've crashed ..."); // TODO: Funny messages
|
||||
lv_obj_align(top_label, LV_ALIGN_TOP_MID, 0, 2);
|
||||
|
||||
lv_obj_add_event_cb(parent, onContinuePressed, LV_EVENT_SHORT_CLICKED, nullptr);
|
||||
auto* top_label = lv_label_create(parent);
|
||||
lv_label_set_text(top_label, "Oops! We've crashed ..."); // TODO: Funny messages
|
||||
lv_obj_align(top_label, LV_ALIGN_TOP_MID, 0, 2);
|
||||
auto* bottom_label = lv_label_create(parent);
|
||||
if (device_has_active_by_type(&POINTER_TYPE)) {
|
||||
lv_label_set_text(bottom_label, "Tap screen to continue");
|
||||
} else {
|
||||
lv_label_set_text(bottom_label, "Reboot device to continue");
|
||||
}
|
||||
lv_obj_align(bottom_label, LV_ALIGN_BOTTOM_MID, 0, -2);
|
||||
|
||||
auto* bottom_label = lv_label_create(parent);
|
||||
if (device_has_active_by_type(&POINTER_TYPE)) {
|
||||
lv_label_set_text(bottom_label, "Tap screen to continue");
|
||||
} else {
|
||||
lv_label_set_text(bottom_label, "Reboot device to continue");
|
||||
}
|
||||
lv_obj_align(bottom_label, LV_ALIGN_BOTTOM_MID, 0, -2);
|
||||
std::string url = getUrlFromCrashData();
|
||||
LOG_I(TAG, "%s", url.c_str());
|
||||
size_t url_length = url.length();
|
||||
|
||||
std::string url = getUrlFromCrashData();
|
||||
LOG_I(TAG, "%s", url.c_str());
|
||||
size_t url_length = url.length();
|
||||
int qr_version;
|
||||
if (!getQrVersionForBinaryDataLength(url_length, qr_version)) {
|
||||
LOG_E(TAG, "QR is too large");
|
||||
ctx->hasFatalError = true;
|
||||
return;
|
||||
}
|
||||
|
||||
int qr_version;
|
||||
if (!getQrVersionForBinaryDataLength(url_length, qr_version)) {
|
||||
LOG_E(TAG, "QR is too large");
|
||||
stop(manifest.appId);
|
||||
return;
|
||||
}
|
||||
LOG_I(TAG, "QR version %d (length: %d)", qr_version, (int)url_length);
|
||||
auto qrcodeData = std::make_shared<uint8_t[]>(qrcode_getBufferSize(qr_version));
|
||||
if (qrcodeData == nullptr) {
|
||||
LOG_E(TAG, "Failed to allocate QR buffer");
|
||||
ctx->hasFatalError = true;
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_I(TAG, "QR version %d (length: %d)", qr_version, (int)url_length);
|
||||
auto qrcodeData = std::make_shared<uint8_t[]>(qrcode_getBufferSize(qr_version));
|
||||
if (qrcodeData == nullptr) {
|
||||
LOG_E(TAG, "Failed to allocate QR buffer");
|
||||
stop(manifest.appId);
|
||||
return;
|
||||
}
|
||||
QRCode qrcode;
|
||||
LOG_I(TAG, "QR init text");
|
||||
if (qrcode_initText(&qrcode, qrcodeData.get(), qr_version, ECC_LOW, url.c_str()) != 0) {
|
||||
LOG_E(TAG, "QR init text failed");
|
||||
ctx->hasFatalError = true;
|
||||
return;
|
||||
}
|
||||
|
||||
QRCode qrcode;
|
||||
LOG_I(TAG, "QR init text");
|
||||
if (qrcode_initText(&qrcode, qrcodeData.get(), qr_version, ECC_LOW, url.c_str()) != 0) {
|
||||
LOG_E(TAG, "QR init text failed");
|
||||
stop(manifest.appId);
|
||||
return;
|
||||
}
|
||||
LOG_I(TAG, "QR size: %d", qrcode.size);
|
||||
|
||||
LOG_I(TAG, "QR size: %d", qrcode.size);
|
||||
// Calculate QR dot size
|
||||
int32_t top_label_height = lv_obj_get_height(top_label) + 2;
|
||||
int32_t bottom_label_height = lv_obj_get_height(bottom_label) + 2;
|
||||
LOG_I(TAG, "Create canvas");
|
||||
int32_t available_height = parent_height - top_label_height - bottom_label_height;
|
||||
int32_t available_width = lv_display_get_horizontal_resolution(display);
|
||||
int32_t smallest_size = std::min(available_height, available_width);
|
||||
int32_t pixel_size;
|
||||
if (qrcode.size * 2 <= smallest_size) {
|
||||
pixel_size = 2;
|
||||
} else if (qrcode.size <= smallest_size) {
|
||||
pixel_size = 1;
|
||||
} else {
|
||||
LOG_E(TAG, "QR code won't fit screen");
|
||||
ctx->hasFatalError = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate QR dot size
|
||||
int32_t top_label_height = lv_obj_get_height(top_label) + 2;
|
||||
int32_t bottom_label_height = lv_obj_get_height(bottom_label) + 2;
|
||||
LOG_I(TAG, "Create canvas");
|
||||
int32_t available_height = parent_height - top_label_height - bottom_label_height;
|
||||
int32_t available_width = lv_display_get_horizontal_resolution(display);
|
||||
int32_t smallest_size = std::min(available_height, available_width);
|
||||
int32_t pixel_size;
|
||||
if (qrcode.size * 2 <= smallest_size) {
|
||||
pixel_size = 2;
|
||||
} else if (qrcode.size <= smallest_size) {
|
||||
pixel_size = 1;
|
||||
} else {
|
||||
LOG_E(TAG, "QR code won't fit screen");
|
||||
stop(manifest.appId);
|
||||
return;
|
||||
}
|
||||
auto* canvas = lv_canvas_create(parent);
|
||||
lv_obj_set_size(canvas, pixel_size * qrcode.size, pixel_size * qrcode.size);
|
||||
lv_obj_align(canvas, LV_ALIGN_CENTER, 0, 0);
|
||||
lv_canvas_fill_bg(canvas, lv_color_black(), LV_OPA_COVER);
|
||||
lv_obj_set_content_height(canvas, qrcode.size * pixel_size);
|
||||
lv_obj_set_content_width(canvas, qrcode.size * pixel_size);
|
||||
|
||||
auto* canvas = lv_canvas_create(parent);
|
||||
lv_obj_set_size(canvas, pixel_size * qrcode.size, pixel_size * qrcode.size);
|
||||
lv_obj_align(canvas, LV_ALIGN_CENTER, 0, 0);
|
||||
lv_canvas_fill_bg(canvas, lv_color_black(), LV_OPA_COVER);
|
||||
lv_obj_set_content_height(canvas, qrcode.size * pixel_size);
|
||||
lv_obj_set_content_width(canvas, qrcode.size * pixel_size);
|
||||
LOG_I(TAG, "Create draw buffer");
|
||||
auto* draw_buf = lv_draw_buf_create(pixel_size * qrcode.size, pixel_size * qrcode.size, LV_COLOR_FORMAT_RGB565, LV_STRIDE_AUTO);
|
||||
if (draw_buf == nullptr) {
|
||||
LOG_E(TAG, "Failed to allocate draw buffer");
|
||||
ctx->hasFatalError = true;
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_I(TAG, "Create draw buffer");
|
||||
auto* draw_buf = lv_draw_buf_create(pixel_size * qrcode.size, pixel_size * qrcode.size, LV_COLOR_FORMAT_RGB565, LV_STRIDE_AUTO);
|
||||
if (draw_buf == nullptr) {
|
||||
LOG_E(TAG, "Failed to allocate draw buffer");
|
||||
stop(manifest.appId);
|
||||
return;
|
||||
}
|
||||
lv_canvas_set_draw_buf(canvas, draw_buf);
|
||||
|
||||
lv_canvas_set_draw_buf(canvas, draw_buf);
|
||||
|
||||
for (uint8_t y = 0; y < qrcode.size; y++) {
|
||||
for (uint8_t x = 0; x < qrcode.size; x++) {
|
||||
bool colored = qrcode_getModule(&qrcode, x, y);
|
||||
auto color = colored ? lv_color_white() : lv_color_black();
|
||||
int32_t pos_x = x * pixel_size;
|
||||
int32_t pos_y = y * pixel_size;
|
||||
for (int px = 0; px < pixel_size; px++) {
|
||||
for (int py = 0; py < pixel_size; py++) {
|
||||
lv_canvas_set_px(canvas, pos_x + px, pos_y + py, color, LV_OPA_COVER);
|
||||
}
|
||||
for (uint8_t y = 0; y < qrcode.size; y++) {
|
||||
for (uint8_t x = 0; x < qrcode.size; x++) {
|
||||
bool colored = qrcode_getModule(&qrcode, x, y);
|
||||
auto color = colored ? lv_color_white() : lv_color_black();
|
||||
int32_t pos_x = x * pixel_size;
|
||||
int32_t pos_y = y * pixel_size;
|
||||
for (int px = 0; px < pixel_size; px++) {
|
||||
for (int py = 0; py < pixel_size; py++) {
|
||||
lv_canvas_set_px(canvas, pos_x + px, pos_y + py, color, LV_OPA_COVER);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "CrashDiagnostics",
|
||||
.appName = "Crash Diagnostics",
|
||||
.appCategory = Category::System,
|
||||
.appFlags = AppManifest::Flags::Hidden,
|
||||
.createApp = create<CrashDiagnosticsApp>
|
||||
};
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
Context ctx {};
|
||||
ctx.appInstanceId = appInstanceId;
|
||||
|
||||
void start() {
|
||||
app::start(manifest.appId);
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
|
||||
if (!ctx.hasFatalError) {
|
||||
bool shouldClose = false;
|
||||
while (!shouldClose) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
switch (event.type) {
|
||||
case APP_EVENT_CLOSE:
|
||||
app_manager_finish(appInstanceId);
|
||||
shouldClose = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
app_manager_finish(appInstanceId);
|
||||
}
|
||||
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
bool continuePressed = ctx.continuePressed;
|
||||
|
||||
if (continuePressed) {
|
||||
launcher::start();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
#endif
|
||||
void start() {
|
||||
uint32_t instanceId = 0;
|
||||
app_manager_start(manifest.id, &instanceId);
|
||||
}
|
||||
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "CrashDiagnostics",
|
||||
.name = "Crash Diagnostics",
|
||||
.category = APP_CATEGORY_SYSTEM,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
|
||||
.flags = APP_MANIFEST_FLAG_HIDDEN,
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
#endif
|
||||
|
||||
@@ -2,179 +2,236 @@
|
||||
|
||||
#include <Tactility/Tactility.h>
|
||||
#include <Tactility/Timer.h>
|
||||
#include <Tactility/app/AppManifest.h>
|
||||
#include <Tactility/lvgl/Style.h>
|
||||
#include <Tactility/lvgl/Toolbar.h>
|
||||
#include <Tactility/service/development/DevelopmentService.h>
|
||||
#include <Tactility/service/development/DevelopmentSettings.h>
|
||||
#include <Tactility/service/loader/Loader.h>
|
||||
#include <Tactility/service/wifi/Wifi.h>
|
||||
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <tactility/log.h>
|
||||
|
||||
#include <lvgl/icons/shared.h>
|
||||
#include <lvgl.h>
|
||||
#include <lvgl/lvgl.h>
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace tt::app::development {
|
||||
|
||||
constexpr auto* TAG = "Development";
|
||||
extern const AppManifest manifest;
|
||||
|
||||
class DevelopmentApp final : public App {
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
namespace {
|
||||
|
||||
struct Context {
|
||||
uint32_t appInstanceId;
|
||||
|
||||
lv_obj_t* enableSwitch = nullptr;
|
||||
lv_obj_t* enableOnBootSwitch = nullptr;
|
||||
lv_obj_t* statusLabel = nullptr;
|
||||
std::shared_ptr<service::development::DevelopmentService> service;
|
||||
std::unique_ptr<Timer> timer;
|
||||
};
|
||||
|
||||
Timer timer = Timer(Timer::Type::Periodic, pdMS_TO_TICKS(1000), [this] {
|
||||
|
||||
void updateViewState(Context* ctx);
|
||||
|
||||
void onBackPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
|
||||
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
|
||||
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
|
||||
// deadlock against itself.
|
||||
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(ctx->appInstanceId, &closeEvent);
|
||||
}
|
||||
|
||||
void onEnableSwitchChanged(lv_event_t* event) {
|
||||
lv_event_code_t code = lv_event_get_code(event);
|
||||
auto* widget = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
if (code == LV_EVENT_VALUE_CHANGED) {
|
||||
bool is_on = lv_obj_has_state(widget, LV_STATE_CHECKED);
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
bool is_changed = is_on != ctx->service->isEnabled();
|
||||
if (is_changed) {
|
||||
ctx->service->setEnabled(is_on);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void onEnableOnBootSwitchChanged(lv_event_t* event) {
|
||||
lv_event_code_t code = lv_event_get_code(event);
|
||||
auto* widget = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
if (code == LV_EVENT_VALUE_CHANGED) {
|
||||
bool is_on = lv_obj_has_state(widget, LV_STATE_CHECKED);
|
||||
bool is_changed = is_on != service::development::shouldEnableOnBoot();
|
||||
if (is_changed) {
|
||||
// Dispatch it, so file IO doesn't block the UI
|
||||
getMainDispatcher().dispatch([is_on] {
|
||||
service::development::setEnableOnBoot(is_on);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void updateViewState(Context* ctx) {
|
||||
if (!ctx->service->isEnabled()) {
|
||||
lv_label_set_text(ctx->statusLabel, "Service disabled");
|
||||
} else if (service::wifi::getRadioState() != service::wifi::RadioState::ConnectionActive) {
|
||||
lv_label_set_text(ctx->statusLabel, "Waiting for connection...");
|
||||
} else { // enabled and connected to wifi
|
||||
auto ip = service::wifi::getIp();
|
||||
if (ip.empty()) {
|
||||
lv_label_set_text(ctx->statusLabel, "Waiting for IP...");
|
||||
} else {
|
||||
const std::string status = std::format("Available at {}", ip);
|
||||
lv_label_set_text(ctx->statusLabel, status.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Development");
|
||||
// The global toolbar nav callback only knows how to stop old-model apps.
|
||||
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
|
||||
|
||||
ctx->enableSwitch = lvgl_toolbar_add_switch_action(toolbar);
|
||||
lv_obj_add_event_cb(ctx->enableSwitch, onEnableSwitchChanged, LV_EVENT_VALUE_CHANGED, ctx);
|
||||
|
||||
if (ctx->service->isEnabled()) {
|
||||
lv_obj_add_state(ctx->enableSwitch, LV_STATE_CHECKED);
|
||||
} else {
|
||||
lv_obj_remove_state(ctx->enableSwitch, LV_STATE_CHECKED);
|
||||
}
|
||||
|
||||
// Wrappers
|
||||
|
||||
lv_obj_t* content_wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_width(content_wrapper, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(content_wrapper, 1);
|
||||
lv_obj_set_flex_flow(content_wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_border_width(content_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lvgl::obj_set_style_bg_invisible(content_wrapper);
|
||||
|
||||
// Enable on boot
|
||||
|
||||
lv_obj_t* enable_wrapper = lv_obj_create(content_wrapper);
|
||||
lv_obj_set_size(enable_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lvgl::obj_set_style_bg_invisible(enable_wrapper);
|
||||
lv_obj_set_style_border_width(enable_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_all(enable_wrapper, 0, LV_STATE_DEFAULT);
|
||||
|
||||
lv_obj_t* enable_label = lv_label_create(enable_wrapper);
|
||||
lv_label_set_text(enable_label, "Enable on boot");
|
||||
lv_obj_align(enable_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
|
||||
ctx->enableOnBootSwitch = lv_switch_create(enable_wrapper);
|
||||
lv_obj_add_event_cb(ctx->enableOnBootSwitch, onEnableOnBootSwitchChanged, LV_EVENT_VALUE_CHANGED, ctx);
|
||||
lv_obj_align(ctx->enableOnBootSwitch, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
if (service::development::shouldEnableOnBoot()) {
|
||||
lv_obj_add_state(ctx->enableOnBootSwitch, LV_STATE_CHECKED);
|
||||
} else {
|
||||
lv_obj_remove_state(ctx->enableOnBootSwitch, LV_STATE_CHECKED);
|
||||
}
|
||||
|
||||
// Status
|
||||
|
||||
ctx->statusLabel = lv_label_create(content_wrapper);
|
||||
|
||||
// Warning
|
||||
|
||||
auto warning_label = lv_label_create(content_wrapper);
|
||||
lv_label_set_text(warning_label, "This feature is experimental and uses an unsecured http connection.");
|
||||
lv_obj_set_width(warning_label, LV_PCT(100));
|
||||
lv_label_set_long_mode(warning_label, LV_LABEL_LONG_WRAP);
|
||||
if (lv_display_get_color_format(lv_obj_get_display(parent)) != LV_COLOR_FORMAT_L8) {
|
||||
lv_obj_set_style_text_color(warning_label, lv_color_make(0xff, 0xff, 0x00), LV_STATE_DEFAULT);
|
||||
}
|
||||
|
||||
updateViewState(ctx);
|
||||
}
|
||||
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
Context ctx {};
|
||||
ctx.appInstanceId = appInstanceId;
|
||||
ctx.service = service::development::findService();
|
||||
|
||||
if (ctx.service == nullptr) {
|
||||
LOG_E(TAG, "Service not found");
|
||||
// No window/subscription was ever created - matches the old model, where onCreate()
|
||||
// aborting the app meant onShow() was never called either.
|
||||
app_manager_finish(appInstanceId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
|
||||
ctx.timer = std::make_unique<Timer>(Timer::Type::Periodic, pdMS_TO_TICKS(1000), [&ctx, window] {
|
||||
if (lvgl_is_running()) {
|
||||
lvgl_lock();
|
||||
updateViewState();
|
||||
// Widgets only exist while this window is topmost - skip otherwise. Another app
|
||||
// (started non-modally, e.g. via app_manager_start()) can bury this window without
|
||||
// stopping this instance or notifying it; window_manager deletes a buried window's
|
||||
// widgets, so touching ctx->statusLabel here would use-after-free it.
|
||||
if (window_manager_get_state(window) == WINDOW_STATE_GRANTED) {
|
||||
updateViewState(&ctx);
|
||||
}
|
||||
lvgl_unlock();
|
||||
}
|
||||
});
|
||||
ctx.timer->start();
|
||||
|
||||
static void onEnableSwitchChanged(lv_event_t* event) {
|
||||
lv_event_code_t code = lv_event_get_code(event);
|
||||
auto* widget = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
if (code == LV_EVENT_VALUE_CHANGED) {
|
||||
bool is_on = lv_obj_has_state(widget, LV_STATE_CHECKED);
|
||||
auto* app = static_cast<DevelopmentApp*>(lv_event_get_user_data(event));
|
||||
bool is_changed = is_on != app->service->isEnabled();
|
||||
if (is_changed) {
|
||||
app->service->setEnabled(is_on);
|
||||
}
|
||||
bool shouldClose = false;
|
||||
while (!shouldClose) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
switch (event.type) {
|
||||
case APP_EVENT_CLOSE:
|
||||
app_manager_finish(appInstanceId);
|
||||
shouldClose = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static void onEnableOnBootSwitchChanged(lv_event_t* event) {
|
||||
lv_event_code_t code = lv_event_get_code(event);
|
||||
auto* widget = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
if (code == LV_EVENT_VALUE_CHANGED) {
|
||||
bool is_on = lv_obj_has_state(widget, LV_STATE_CHECKED);
|
||||
bool is_changed = is_on != service::development::shouldEnableOnBoot();
|
||||
if (is_changed) {
|
||||
// Dispatch it, so file IO doesn't block the UI
|
||||
getMainDispatcher().dispatch([is_on] {
|
||||
service::development::setEnableOnBoot(is_on);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Equivalent of the old model's onHide(): ensure the periodic update isn't already happening.
|
||||
lvgl_lock();
|
||||
ctx.timer->stop();
|
||||
lvgl_unlock();
|
||||
|
||||
void updateViewState() {
|
||||
if (!service->isEnabled()) {
|
||||
lv_label_set_text(statusLabel, "Service disabled");
|
||||
} else if (service::wifi::getRadioState() != service::wifi::RadioState::ConnectionActive) {
|
||||
lv_label_set_text(statusLabel, "Waiting for connection...");
|
||||
} else { // enabled and connected to wifi
|
||||
auto ip = service::wifi::getIp();
|
||||
if (ip.empty()) {
|
||||
lv_label_set_text(statusLabel, "Waiting for IP...");
|
||||
} else {
|
||||
const std::string status = std::format("Available at {}", ip);
|
||||
lv_label_set_text(statusLabel, status.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
public:
|
||||
|
||||
void onCreate(AppContext& appContext) override {
|
||||
service = service::development::findService();
|
||||
if (service == nullptr) {
|
||||
LOG_E(TAG, "Service not found");
|
||||
stop(manifest.appId);
|
||||
}
|
||||
}
|
||||
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override {
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
lv_obj_t* toolbar = lvgl::toolbar_create(parent, app);
|
||||
|
||||
enableSwitch = lvgl_toolbar_add_switch_action(toolbar);
|
||||
lv_obj_add_event_cb(enableSwitch, onEnableSwitchChanged, LV_EVENT_VALUE_CHANGED, this);
|
||||
|
||||
if (service->isEnabled()) {
|
||||
lv_obj_add_state(enableSwitch, LV_STATE_CHECKED);
|
||||
} else {
|
||||
lv_obj_remove_state(enableSwitch, LV_STATE_CHECKED);
|
||||
}
|
||||
|
||||
// Wrappers
|
||||
|
||||
lv_obj_t* content_wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_width(content_wrapper, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(content_wrapper, 1);
|
||||
lv_obj_set_flex_flow(content_wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_border_width(content_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lvgl::obj_set_style_bg_invisible(content_wrapper);
|
||||
|
||||
// Enable on boot
|
||||
|
||||
lv_obj_t* enable_wrapper = lv_obj_create(content_wrapper);
|
||||
lv_obj_set_size(enable_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lvgl::obj_set_style_bg_invisible(enable_wrapper);
|
||||
lv_obj_set_style_border_width(enable_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_all(enable_wrapper, 0, LV_STATE_DEFAULT);
|
||||
|
||||
lv_obj_t* enable_label = lv_label_create(enable_wrapper);
|
||||
lv_label_set_text(enable_label, "Enable on boot");
|
||||
lv_obj_align(enable_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
|
||||
enableOnBootSwitch = lv_switch_create(enable_wrapper);
|
||||
lv_obj_add_event_cb(enableOnBootSwitch, onEnableOnBootSwitchChanged, LV_EVENT_VALUE_CHANGED, this);
|
||||
lv_obj_align(enableOnBootSwitch, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
if (service::development::shouldEnableOnBoot()) {
|
||||
lv_obj_add_state(enableOnBootSwitch, LV_STATE_CHECKED);
|
||||
} else {
|
||||
lv_obj_remove_state(enableOnBootSwitch, LV_STATE_CHECKED);
|
||||
}
|
||||
|
||||
// Status
|
||||
|
||||
statusLabel = lv_label_create(content_wrapper);
|
||||
|
||||
// Warning
|
||||
|
||||
auto warning_label = lv_label_create(content_wrapper);
|
||||
lv_label_set_text(warning_label, "This feature is experimental and uses an unsecured http connection.");
|
||||
lv_obj_set_width(warning_label, LV_PCT(100));
|
||||
lv_label_set_long_mode(warning_label, LV_LABEL_LONG_WRAP);
|
||||
if (lv_display_get_color_format(lv_obj_get_display(parent)) != LV_COLOR_FORMAT_L8) {
|
||||
lv_obj_set_style_text_color(warning_label, lv_color_make(0xff, 0xff, 0x00), LV_STATE_DEFAULT);
|
||||
}
|
||||
|
||||
updateViewState();
|
||||
|
||||
timer.start();
|
||||
}
|
||||
|
||||
void onHide(AppContext& appContext) override {
|
||||
lvgl_lock();
|
||||
// Ensure that the update isn't already happening
|
||||
timer.stop();
|
||||
lvgl_unlock();
|
||||
}
|
||||
};
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "Development",
|
||||
.appName = "Development",
|
||||
.appIcon = LVGL_ICON_SHARED_DEVICES,
|
||||
.appCategory = Category::Settings,
|
||||
.createApp = create<DevelopmentApp>
|
||||
};
|
||||
|
||||
void start() {
|
||||
app::start(manifest.appId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
#endif // ESP_PLATFORM
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "Development",
|
||||
.name = "Development",
|
||||
.category = APP_CATEGORY_SETTINGS,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
#endif // ESP_PLATFORM
|
||||
|
||||
@@ -1,50 +1,76 @@
|
||||
#include <Tactility/app/files/View.h>
|
||||
#include <Tactility/app/files/State.h>
|
||||
#include <Tactility/app/AppContext.h>
|
||||
|
||||
#include <Tactility/service/loader/Loader.h>
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace tt::app::files {
|
||||
|
||||
extern const AppManifest manifest;
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
class FilesApp final : public App {
|
||||
namespace {
|
||||
|
||||
std::unique_ptr<View> view;
|
||||
std::shared_ptr<State> state;
|
||||
|
||||
public:
|
||||
|
||||
FilesApp() {
|
||||
state = std::make_shared<State>();
|
||||
view = std::make_unique<View>(state);
|
||||
}
|
||||
|
||||
void onShow(AppContext& appContext, lv_obj_t* parent) override {
|
||||
view->init(appContext, parent);
|
||||
}
|
||||
|
||||
void onResult(AppContext& appContext, LaunchId launchId, Result result, std::unique_ptr<Bundle> bundle) override {
|
||||
view->onResult(launchId, result, std::move(bundle));
|
||||
}
|
||||
|
||||
void onHide(AppContext& appContext) override {
|
||||
view->deinit(appContext);
|
||||
}
|
||||
struct CreateContext {
|
||||
View* view;
|
||||
uint32_t appInstanceId;
|
||||
};
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "Files",
|
||||
.appName = "Files",
|
||||
.appCategory = Category::System,
|
||||
.appFlags = AppManifest::Flags::Hidden,
|
||||
.createApp = create<FilesApp>
|
||||
};
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<CreateContext*>(userData);
|
||||
ctx->view->init(ctx->appInstanceId, parent);
|
||||
}
|
||||
|
||||
void start() {
|
||||
app::start(manifest.appId);
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
auto state = std::make_shared<State>();
|
||||
View view(state);
|
||||
CreateContext createContext { &view, appInstanceId };
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &createContext);
|
||||
|
||||
bool shouldClose = false;
|
||||
while (!shouldClose) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
switch (event.type) {
|
||||
case APP_EVENT_CLOSE:
|
||||
app_manager_finish(appInstanceId);
|
||||
shouldClose = true;
|
||||
break;
|
||||
case APP_EVENT_RESULT:
|
||||
view.onResult(event.result.launch_id, event.result.result);
|
||||
app_manager_stop(event.result.launch_id);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
view.deinit();
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "Files",
|
||||
.name = "Files",
|
||||
.category = APP_CATEGORY_SYSTEM,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
|
||||
.flags = APP_MANIFEST_FLAG_HIDDEN,
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
#include <app/install.h>
|
||||
#include <app/event.h>
|
||||
|
||||
#include <lvgl/lvgl.h>
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
|
||||
#include <Tactility/app/files/SupportedFiles.h>
|
||||
#include <Tactility/app/files/View.h>
|
||||
#include <Tactility/Platform.h>
|
||||
#include <Tactility/StringUtils.h>
|
||||
#include <Tactility/Tactility.h>
|
||||
#include <Tactility/app/alertdialog/AlertDialog.h>
|
||||
#include <Tactility/app/imageviewer/ImageViewer.h>
|
||||
#include <Tactility/app/inputdialog/InputDialog.h>
|
||||
#include <Tactility/app/notes/Notes.h>
|
||||
#include <Tactility/file/File.h>
|
||||
#include <Tactility/lvgl/Toolbar.h>
|
||||
#include <Tactility/Platform.h>
|
||||
#include <Tactility/StringUtils.h>
|
||||
#include <Tactility/Tactility.h>
|
||||
|
||||
#include <tactility/check.h>
|
||||
#include <tactility/device.h>
|
||||
@@ -16,17 +21,11 @@
|
||||
#include <tactility/filesystem/file_mutex.h>
|
||||
#include <tactility/log.h>
|
||||
|
||||
#include <lvgl/lvgl.h>
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
#include <Tactility/service/loader/Loader.h>
|
||||
#endif
|
||||
|
||||
namespace tt::app::files {
|
||||
|
||||
constexpr auto* TAG = "Files";
|
||||
@@ -38,6 +37,11 @@ static void dirEntryListScrollBeginCallback(lv_event_t* event) {
|
||||
view->onDirEntryListScrollBegin();
|
||||
}
|
||||
|
||||
static void onBackPressedCallback(lv_event_t* event) {
|
||||
auto* view = static_cast<files::View*>(lv_event_get_user_data(event));
|
||||
view->onBackPressed();
|
||||
}
|
||||
|
||||
static void onDirEntryPressedCallback(lv_event_t* event) {
|
||||
auto* view = static_cast<View*>(lv_event_get_user_data(event));
|
||||
auto* button = lv_event_get_target_obj(event);
|
||||
@@ -225,8 +229,8 @@ void View::viewFile(const std::string& path, const std::string& filename) {
|
||||
// install(filename);
|
||||
auto message = std::format("Do you want to install {}?", filename);
|
||||
installAppPath = processed_filepath;
|
||||
auto choices = std::vector {"Yes", "No"};
|
||||
installAppLaunchId = alertdialog::start("Install?", message, choices);
|
||||
auto choices = std::vector<std::string> {"Yes", "No"};
|
||||
installDialogId = alertdialog::start(appInstanceId, "Install?", message, choices);
|
||||
#endif
|
||||
} else if (isSupportedImageFile(filename)) {
|
||||
imageviewer::start(processed_filepath);
|
||||
@@ -371,6 +375,15 @@ void View::createDirEntryWidget(lv_obj_t* list, dirent& dir_entry) {
|
||||
lv_obj_add_event_cb(button, &onDirEntryLongPressedCallback, LV_EVENT_LONG_PRESSED, this);
|
||||
}
|
||||
|
||||
void View::onBackPressed() {
|
||||
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
|
||||
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
|
||||
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
|
||||
// deadlock against itself.
|
||||
AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(appInstanceId, &event);
|
||||
}
|
||||
|
||||
void View::onNavigateUpPressed() {
|
||||
if (state->getCurrentPath() != "/") {
|
||||
LOG_I(TAG, "Navigating upwards");
|
||||
@@ -387,7 +400,7 @@ void View::onRenamePressed() {
|
||||
std::string entry_name = state->getSelectedChildEntry();
|
||||
LOG_I(TAG, "Pending rename %s", entry_name.c_str());
|
||||
state->setPendingAction(State::ActionRename);
|
||||
inputdialog::start("Rename", "", entry_name);
|
||||
inputdialog::start(appInstanceId, "Rename", "", entry_name);
|
||||
}
|
||||
|
||||
void View::onDeletePressed() {
|
||||
@@ -396,19 +409,19 @@ void View::onDeletePressed() {
|
||||
state->setPendingAction(State::ActionDelete);
|
||||
std::string message = "Do you want to delete this?\n" + file_path;
|
||||
const std::vector<std::string> choices = {"Yes", "No"};
|
||||
alertdialog::start("Are you sure?", message, choices);
|
||||
alertdialog::start(appInstanceId, "Are you sure?", message, choices);
|
||||
}
|
||||
|
||||
void View::onNewFilePressed() {
|
||||
LOG_I(TAG, "Creating new file");
|
||||
state->setPendingAction(State::ActionCreateFile);
|
||||
inputdialog::start("New File", "Enter filename:", "");
|
||||
inputdialog::start(appInstanceId, "New File", "Enter filename:", "");
|
||||
}
|
||||
|
||||
void View::onNewFolderPressed() {
|
||||
LOG_I(TAG, "Creating new folder");
|
||||
state->setPendingAction(State::ActionCreateFolder);
|
||||
inputdialog::start("New Folder", "Enter folder name:", "");
|
||||
inputdialog::start(appInstanceId, "New Folder", "Enter folder name:", "");
|
||||
}
|
||||
|
||||
void View::showActions() {
|
||||
@@ -445,7 +458,7 @@ void View::onEjectPressed() {
|
||||
Device* msc_dev = nullptr;
|
||||
if (device_get_first_active_by_type(&USB_HOST_MSC_TYPE, &msc_dev) != ERROR_NONE || !usb_msc_eject(msc_dev, mount_path.c_str())) {
|
||||
LOG_W(TAG, "usb_msc_eject: %s not found", mount_path.c_str());
|
||||
alertdialog::start("Eject failed", "Could not eject \"" + file::getLastPathSegment(mount_path) + "\".");
|
||||
alertdialog::start(appInstanceId, "Eject failed", "Could not eject \"" + file::getLastPathSegment(mount_path) + "\".");
|
||||
}
|
||||
|
||||
if (msc_dev) {
|
||||
@@ -528,11 +541,15 @@ void View::update(size_t start_index) {
|
||||
lvgl_unlock();
|
||||
}
|
||||
|
||||
void View::init(const AppContext& appContext, lv_obj_t* parent) {
|
||||
void View::init(uint32_t appInstanceId, lv_obj_t* parent) {
|
||||
this->appInstanceId = appInstanceId;
|
||||
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* toolbar = lvgl::toolbar_create(parent, appContext);
|
||||
auto* toolbar = lvgl_toolbar_create(parent, "Files");
|
||||
// The global toolbar nav callback only knows how to stop old-model apps.
|
||||
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressedCallback, this);
|
||||
navigate_up_button = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_UP, &onNavigateUpPressedCallback, this);
|
||||
new_file_button = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_FILE, &onNewFilePressedCallback, this);
|
||||
new_folder_button = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_DIRECTORY, &onNewFolderPressedCallback, this);
|
||||
@@ -574,26 +591,23 @@ void View::onNavigate() {
|
||||
}
|
||||
}
|
||||
|
||||
void View::onResult(LaunchId launchId, Result result, std::unique_ptr<Bundle> bundle) {
|
||||
if (result != Result::Ok || bundle == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
launchId == installAppLaunchId &&
|
||||
result == Result::Ok &&
|
||||
alertdialog::getResultIndex(*bundle) == 0
|
||||
) {
|
||||
install(installAppPath);
|
||||
void View::onResult(uint32_t launchId, int32_t result) {
|
||||
if (launchId == installDialogId && result == 0) {
|
||||
app_install(installAppPath.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
std::string filepath = state->getSelectedChildPath();
|
||||
LOG_I(TAG, "Result for %s", filepath.c_str());
|
||||
|
||||
// Text-entry result (rename/new file/new folder); empty for Cancel, or for a dialog that
|
||||
// doesn't produce text (delete/paste confirmations) - those switch cases below only look at
|
||||
// `result`, not this.
|
||||
std::string resultText = (result == 0) ? inputdialog::getLastText() : std::string();
|
||||
|
||||
switch (state->getPendingAction()) {
|
||||
case State::ActionDelete: {
|
||||
if (alertdialog::getResultIndex(*bundle) == 0) {
|
||||
if (result == 0) {
|
||||
if (file::isDirectory(filepath)) {
|
||||
if (!file::deleteRecursively(filepath)) {
|
||||
LOG_W(TAG, "Failed to delete %s", filepath.c_str());
|
||||
@@ -611,7 +625,7 @@ void View::onResult(LaunchId launchId, Result result, std::unique_ptr<Bundle> bu
|
||||
break;
|
||||
}
|
||||
case State::ActionRename: {
|
||||
auto new_name = inputdialog::getResult(*bundle);
|
||||
std::string new_name = resultText;
|
||||
if (!new_name.empty() && new_name != state->getSelectedChildEntry()) {
|
||||
std::string rename_to = file::getChildPath(state->getCurrentPath(), new_name);
|
||||
{
|
||||
@@ -620,7 +634,7 @@ void View::onResult(LaunchId launchId, Result result, std::unique_ptr<Bundle> bu
|
||||
if (stat(rename_to.c_str(), &st) == 0) {
|
||||
LOG_W(TAG, "Rename: destination already exists: \"%s\"", rename_to.c_str());
|
||||
state->setPendingAction(State::ActionNone);
|
||||
alertdialog::start("Rename failed", "\"" + new_name + "\" already exists.");
|
||||
alertdialog::start(appInstanceId, "Rename failed", "\"" + new_name + "\" already exists.");
|
||||
break;
|
||||
}
|
||||
if (rename(filepath.c_str(), rename_to.c_str()) == 0) {
|
||||
@@ -636,7 +650,7 @@ void View::onResult(LaunchId launchId, Result result, std::unique_ptr<Bundle> bu
|
||||
break;
|
||||
}
|
||||
case State::ActionCreateFile: {
|
||||
auto filename = inputdialog::getResult(*bundle);
|
||||
std::string filename = resultText;
|
||||
if (!filename.empty()) {
|
||||
std::string new_file_path = file::getChildPath(state->getCurrentPath(), filename);
|
||||
|
||||
@@ -664,7 +678,7 @@ void View::onResult(LaunchId launchId, Result result, std::unique_ptr<Bundle> bu
|
||||
break;
|
||||
}
|
||||
case State::ActionCreateFolder: {
|
||||
auto foldername = inputdialog::getResult(*bundle);
|
||||
std::string foldername = resultText;
|
||||
if (!foldername.empty()) {
|
||||
std::string new_folder_path = file::getChildPath(state->getCurrentPath(), foldername);
|
||||
|
||||
@@ -690,7 +704,7 @@ void View::onResult(LaunchId launchId, Result result, std::unique_ptr<Bundle> bu
|
||||
break;
|
||||
}
|
||||
case State::ActionPaste: {
|
||||
if (alertdialog::getResultIndex(*bundle) == 0) {
|
||||
if (result == 0) {
|
||||
auto clipboard = state->getClipboard();
|
||||
if (clipboard.has_value()) {
|
||||
std::string dst = state->getPendingPasteDst();
|
||||
@@ -712,6 +726,7 @@ void View::onResult(LaunchId launchId, Result result, std::unique_ptr<Bundle> bu
|
||||
LOG_W(TAG, "Overwrite: destination \"%s\" changed since confirmation, aborting", dst.c_str());
|
||||
state->setPendingAction(State::ActionNone);
|
||||
alertdialog::start(
|
||||
appInstanceId,
|
||||
"Overwrite aborted",
|
||||
"\"" + file::getLastPathSegment(dst) + "\" changed while the dialog was open. Please try again."
|
||||
);
|
||||
@@ -729,6 +744,7 @@ void View::onResult(LaunchId launchId, Result result, std::unique_ptr<Bundle> bu
|
||||
LOG_E(TAG, "Overwrite: failed to remove existing destination: \"%s\"", dst.c_str());
|
||||
state->setPendingAction(State::ActionNone);
|
||||
alertdialog::start(
|
||||
appInstanceId,
|
||||
"Overwrite failed",
|
||||
"Could not remove \"" + file::getLastPathSegment(dst) + "\" before overwriting."
|
||||
);
|
||||
@@ -793,7 +809,7 @@ void View::onPastePressed() {
|
||||
state->setPendingPasteDstStat(dst_stat);
|
||||
state->setPendingAction(State::ActionPaste);
|
||||
const std::vector<std::string> choices = {"Overwrite", "Cancel"};
|
||||
alertdialog::start("File exists", "Overwrite \"" + entry_name + "\"?", choices);
|
||||
alertdialog::start(appInstanceId, "File exists", "Overwrite \"" + entry_name + "\"?", choices);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -834,11 +850,12 @@ void View::doPaste(const std::string& src, bool is_cut, const std::string& dst)
|
||||
}
|
||||
} else if (src_delete_failed) {
|
||||
state->setPendingAction(State::ActionNone); // prevent re-trigger on dialog dismiss
|
||||
alertdialog::start("Move incomplete", "\"" + filename + "\" was copied but the original could not be removed.\nPlease delete it manually.");
|
||||
alertdialog::start(appInstanceId, "Move incomplete", "\"" + filename + "\" was copied but the original could not be removed.\nPlease delete it manually.");
|
||||
} else {
|
||||
LOG_E(TAG, "Failed to %s \"%s\" to \"%s\"", is_cut ? "move" : "copy", src.c_str(), dst.c_str());
|
||||
state->setPendingAction(State::ActionNone); // prevent re-trigger on dialog dismiss
|
||||
alertdialog::start(
|
||||
appInstanceId,
|
||||
std::string("Failed to ") + (is_cut ? "move" : "copy"),
|
||||
"\"" + filename + "\" could not be " + (is_cut ? "moved." : "copied.")
|
||||
);
|
||||
@@ -848,7 +865,7 @@ void View::doPaste(const std::string& src, bool is_cut, const std::string& dst)
|
||||
update();
|
||||
}
|
||||
|
||||
void View::deinit(const AppContext& appContext) {
|
||||
void View::deinit() {
|
||||
lv_obj_remove_event_cb(dir_entry_list, dirEntryListScrollBeginCallback);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,78 +1,116 @@
|
||||
#include "Tactility/app/fileselection/FileSelectionPrivate.h"
|
||||
#include "Tactility/app/fileselection/View.h"
|
||||
#include "Tactility/app/fileselection/State.h"
|
||||
#include "Tactility/app/AppContext.h"
|
||||
|
||||
#include <Tactility/Assets.h>
|
||||
#include <Tactility/service/loader/Loader.h>
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace tt::app::fileselection {
|
||||
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
constexpr auto* TAG = "FileSelection";
|
||||
|
||||
extern const AppManifest manifest;
|
||||
namespace {
|
||||
|
||||
std::string getResultPath(const Bundle& bundle) {
|
||||
std::string result;
|
||||
if (bundle.optString("path", result)) {
|
||||
return result;
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
Mode getMode(const Bundle& bundle) {
|
||||
int32_t mode = static_cast<int32_t>(Mode::ExistingOrNew);
|
||||
bundle.optInt32("mode", mode);
|
||||
return static_cast<Mode>(mode);
|
||||
}
|
||||
|
||||
void setMode(Bundle& bundle, Mode mode) {
|
||||
auto mode_int = static_cast<int32_t>(mode);
|
||||
bundle.putInt32("mode", mode_int);
|
||||
}
|
||||
|
||||
class FileSelection : public App {
|
||||
std::unique_ptr<View> view;
|
||||
struct Context {
|
||||
uint32_t appInstanceId;
|
||||
Mode mode;
|
||||
std::shared_ptr<State> state;
|
||||
|
||||
public:
|
||||
FileSelection() {
|
||||
state = std::make_shared<State>();
|
||||
view = std::make_unique<View>(state, [this](const std::string& path) {
|
||||
auto bundle = std::make_unique<Bundle>();
|
||||
bundle->putString("path", path);
|
||||
setResult(Result::Ok, std::move(bundle));
|
||||
stop(manifest.appId);
|
||||
});
|
||||
}
|
||||
|
||||
void onShow(AppContext& appContext, lv_obj_t* parent) override {
|
||||
auto mode = getMode(*appContext.getParameters());
|
||||
view->init(parent, mode);
|
||||
}
|
||||
std::unique_ptr<View> view;
|
||||
// The eventual appMain() return value - see AlertDialog.cpp's Context::result for why this
|
||||
// is a plain (non-atomic) field safely shared between the LVGL thread (writer, before
|
||||
// emitting APP_EVENT_CLOSE) and this app's own thread (reader, after waking from it).
|
||||
int32_t result = 1; // Cancelled - safety-net default if closed without picking a file
|
||||
};
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "FileSelection",
|
||||
.appName = "File Selection",
|
||||
.appCategory = Category::System,
|
||||
.appFlags = AppManifest::Flags::Hidden,
|
||||
.createApp = create<FileSelection>
|
||||
};
|
||||
|
||||
LaunchId startForExistingFile() {
|
||||
auto bundle = std::make_shared<Bundle>();
|
||||
setMode(*bundle, Mode::Existing);
|
||||
return start(manifest.appId, bundle);
|
||||
// The last picked path. Static rather than per-instance: simple, and in practice only one
|
||||
// FileSelection dialog is ever open at a time. Written on the LVGL thread (View's select-button
|
||||
// callback, before emitting APP_EVENT_CLOSE); read by the parent via getLastPath() after
|
||||
// receiving that event - safe without a lock for the same reason Context::result is (see
|
||||
// AlertDialog.cpp).
|
||||
std::string lastPath;
|
||||
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
ctx->view->init(parent, ctx->mode);
|
||||
}
|
||||
|
||||
LaunchId startForExistingOrNewFile() {
|
||||
auto bundle = std::make_shared<Bundle>();
|
||||
setMode(*bundle, Mode::ExistingOrNew);
|
||||
return start(manifest.appId, bundle);
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
// argv layout: [0]="existing" or "existing_or_new".
|
||||
|
||||
Context ctx {};
|
||||
ctx.appInstanceId = appInstanceId;
|
||||
ctx.mode = (argc > 0 && std::string(argv[0]) == "existing_or_new") ? Mode::ExistingOrNew : Mode::Existing;
|
||||
ctx.state = std::make_shared<State>();
|
||||
ctx.view = std::make_unique<View>(appInstanceId, ctx.state, [&ctx, appInstanceId](const std::string& path) {
|
||||
// Runs on the LVGL task (View::onSelectButtonPressed) - must NOT call app_manager_stop()
|
||||
// here: that bound-waits (thread_join) for this app's own thread to finish, which needs
|
||||
// the LVGL lock (window_manager_remove()) - but this callback runs ON the LVGL task,
|
||||
// which would deadlock against itself. The caller reaps this instance via
|
||||
// app_manager_stop() after it receives the APP_EVENT_RESULT instead.
|
||||
lastPath = path;
|
||||
ctx.result = 0;
|
||||
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(appInstanceId, &closeEvent);
|
||||
});
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
|
||||
while (true) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
if (event.type == APP_EVENT_CLOSE) {
|
||||
app_manager_finish(appInstanceId); // no-op: modal children never supersede anything
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
return ctx.result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string getLastPath() {
|
||||
return lastPath;
|
||||
}
|
||||
|
||||
uint32_t startForExistingFile(uint32_t callerAppInstanceId) {
|
||||
const char* argv[] = { "existing" };
|
||||
uint32_t instanceId = 0;
|
||||
app_manager_start_for_result(manifest.id, callerAppInstanceId, 1, argv, &instanceId);
|
||||
return instanceId;
|
||||
}
|
||||
|
||||
uint32_t startForExistingOrNewFile(uint32_t callerAppInstanceId) {
|
||||
const char* argv[] = { "existing_or_new" };
|
||||
uint32_t instanceId = 0;
|
||||
app_manager_start_for_result(manifest.id, callerAppInstanceId, 1, argv, &instanceId);
|
||||
return instanceId;
|
||||
}
|
||||
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "FileSelection",
|
||||
.name = "File Selection",
|
||||
.category = APP_CATEGORY_SYSTEM,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
|
||||
.flags = APP_MANIFEST_FLAG_HIDDEN,
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
#include <Tactility/app/alertdialog/AlertDialog.h>
|
||||
#include <Tactility/file/File.h>
|
||||
|
||||
#include <app/event.h>
|
||||
|
||||
#include <tactility/check.h>
|
||||
#include <tactility/log.h>
|
||||
|
||||
@@ -15,7 +17,6 @@
|
||||
#include <unistd.h>
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
#include <Tactility/service/loader/Loader.h>
|
||||
#endif
|
||||
|
||||
namespace tt::app::fileselection {
|
||||
@@ -38,6 +39,16 @@ static void onNavigateUpPressedCallback(lv_event_t* event) {
|
||||
|
||||
// endregion
|
||||
|
||||
void View::onBackPressedCallback(lv_event_t* event) {
|
||||
auto* view = static_cast<View*>(lv_event_get_user_data(event));
|
||||
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
|
||||
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
|
||||
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
|
||||
// deadlock against itself.
|
||||
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(view->appInstanceId, &closeEvent);
|
||||
}
|
||||
|
||||
void View::onTapFile(const std::string& path, const std::string& filename) {
|
||||
std::string file_path = path + "/" + filename;
|
||||
|
||||
@@ -183,6 +194,8 @@ void View::init(lv_obj_t* parent, Mode mode) {
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* toolbar = lvgl_toolbar_create(parent, "Select File");
|
||||
// The global toolbar nav callback only knows how to stop old-model apps.
|
||||
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, &onBackPressedCallback, this);
|
||||
navigate_up_button = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_UP, &onNavigateUpPressedCallback, this);
|
||||
|
||||
auto* wrapper = lv_obj_create(parent);
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
#include <lvgl/icons/shared.h>
|
||||
#include <lvgl/lvgl.h>
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
|
||||
#include <Tactility/Tactility.h>
|
||||
#include <Tactility/Timer.h>
|
||||
#include <Tactility/app/AppManifest.h>
|
||||
#include <Tactility/app/alertdialog/AlertDialog.h>
|
||||
#include <Tactility/lvgl/Toolbar.h>
|
||||
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/time.h>
|
||||
@@ -20,287 +25,305 @@
|
||||
#include <gps/gps_settings.h>
|
||||
|
||||
namespace tt::app::addgps {
|
||||
extern AppManifest manifest;
|
||||
extern const ::AppManifest manifest;
|
||||
}
|
||||
|
||||
namespace tt::app::gpssettings {
|
||||
|
||||
extern const AppManifest manifest;
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
class GpsSettingsApp final : public App {
|
||||
namespace {
|
||||
|
||||
struct DeviceRow {
|
||||
Device* device;
|
||||
lv_obj_t* button;
|
||||
lv_obj_t* buttonLabel;
|
||||
bool hasConfiguration = false;
|
||||
size_t configurationIndex = 0;
|
||||
};
|
||||
struct DeviceRow {
|
||||
Device* device;
|
||||
lv_obj_t* button;
|
||||
lv_obj_t* buttonLabel;
|
||||
bool hasConfiguration = false;
|
||||
size_t configurationIndex = 0;
|
||||
};
|
||||
|
||||
std::unique_ptr<Timer> timer;
|
||||
struct Context {
|
||||
uint32_t appInstanceId;
|
||||
lv_obj_t* deviceListWrapper = nullptr;
|
||||
std::vector<DeviceRow> deviceRows;
|
||||
std::atomic<bool> isShown = false;
|
||||
std::unique_ptr<Timer> timer;
|
||||
|
||||
// Set when a delete confirmation is pending; read/cleared on this app's own thread when
|
||||
// the dialog's result arrives.
|
||||
bool hasPendingDelete = false;
|
||||
Device* pendingDeleteDevice = nullptr;
|
||||
size_t pendingDeleteIndex = 0;
|
||||
};
|
||||
|
||||
static void onAddGpsCallback(lv_event_t* event) {
|
||||
auto* app = (GpsSettingsApp*)lv_event_get_user_data(event);
|
||||
app->onAddGps();
|
||||
}
|
||||
|
||||
void onAddGps() {
|
||||
app::start(addgps::manifest.appId);
|
||||
}
|
||||
void rebuildDeviceList(Context* ctx);
|
||||
void updateDeviceStates(Context* ctx);
|
||||
void createWidgets(lv_obj_t* parent, void* userData);
|
||||
|
||||
static void onDeviceButtonCallback(lv_event_t* event) {
|
||||
auto* button = lv_event_get_target_obj(event);
|
||||
auto* device = static_cast<Device*>(lv_obj_get_user_data(button));
|
||||
void onBackPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
|
||||
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
|
||||
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
|
||||
// deadlock against itself.
|
||||
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(ctx->appInstanceId, &closeEvent);
|
||||
}
|
||||
|
||||
bool running = device_is_ready(device);
|
||||
// device_start()/device_stop() are potentially blocking calls, so use a dispatcher to not block the UI
|
||||
getMainDispatcher().dispatch([device, running] {
|
||||
if (running) {
|
||||
device_stop(device);
|
||||
} else {
|
||||
device_start(device);
|
||||
}
|
||||
});
|
||||
}
|
||||
void onAddGpsPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
// Fire-and-forget top-level launch, matching the original (its result never fed back into
|
||||
// this app; rebuildDeviceList() runs fresh whenever this app is resumed regardless).
|
||||
(void)ctx;
|
||||
uint32_t instanceId = 0;
|
||||
app_manager_start(addgps::manifest.id, &instanceId);
|
||||
}
|
||||
|
||||
// Finds the persisted configuration backing `device` (matched by its parent UART's name)
|
||||
// and returns its index into gps_settings_for_each_configuration()'s ordering - the handle
|
||||
// gps_settings_remove_configuration_at() needs to delete exactly this entry, even if another
|
||||
// entry happens to have identical field values.
|
||||
// Devicetree-declared GPS_TYPE devices have no such configuration and never match.
|
||||
static bool findConfigurationIndexForDevice(Device* device, size_t& outIndex) {
|
||||
auto* parent = device_get_parent(device);
|
||||
if (parent == nullptr) {
|
||||
return false;
|
||||
}
|
||||
void onDeviceButtonPressed(lv_event_t* event) {
|
||||
auto* button = lv_event_get_target_obj(event);
|
||||
auto* device = static_cast<Device*>(lv_obj_get_user_data(button));
|
||||
|
||||
struct Context {
|
||||
const char* uartName;
|
||||
size_t* outIndex;
|
||||
bool found;
|
||||
} context = { parent->name, &outIndex, false };
|
||||
|
||||
gps_settings_for_each_configuration(&context, [](const GpsConfiguration* configuration, size_t index, void* untyped_context) {
|
||||
auto* ctx = static_cast<Context*>(untyped_context);
|
||||
if (!ctx->found && strcmp(configuration->uart_name, ctx->uartName) == 0) {
|
||||
*ctx->outIndex = index;
|
||||
ctx->found = true;
|
||||
}
|
||||
});
|
||||
|
||||
return context.found;
|
||||
}
|
||||
|
||||
static void onDeleteButtonCallback(lv_event_t* event) {
|
||||
auto* app = static_cast<GpsSettingsApp*>(lv_event_get_user_data(event));
|
||||
auto* button = lv_event_get_target_obj(event);
|
||||
auto* device = static_cast<Device*>(lv_obj_get_user_data(button));
|
||||
app->onDeleteDevice(device);
|
||||
}
|
||||
|
||||
void onDeleteDevice(Device* device) {
|
||||
for (auto& row : deviceRows) {
|
||||
if (row.device == device && row.hasConfiguration) {
|
||||
pendingDeleteDevice = device;
|
||||
pendingDeleteIndex = row.configurationIndex;
|
||||
hasPendingDelete = true;
|
||||
alertdialog::start("Confirmation", std::string("Do you want to delete ") + device->name + "?", std::vector<std::string> { "Yes", "No" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void createDeviceRow(Device* device) {
|
||||
auto* wrapper = lv_obj_create(deviceListWrapper);
|
||||
lv_obj_set_size(wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_border_width(wrapper, 0, 0);
|
||||
lv_obj_set_style_pad_all(wrapper, 0, 0);
|
||||
|
||||
auto* name_label = lv_label_create(wrapper);
|
||||
char model_name[64];
|
||||
if (gps_get_model_name(device, model_name, sizeof(model_name)) == ERROR_NONE) {
|
||||
lv_label_set_text(name_label, model_name);
|
||||
bool running = device_is_ready(device);
|
||||
// device_start()/device_stop() are potentially blocking calls, so use a dispatcher to not block the UI
|
||||
getMainDispatcher().dispatch([device, running] {
|
||||
if (running) {
|
||||
device_stop(device);
|
||||
} else {
|
||||
lv_label_set_text(name_label, device->name);
|
||||
device_start(device);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
auto* actions_wrapper = lv_obj_create(wrapper);
|
||||
lv_obj_set_size(actions_wrapper, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
||||
lv_obj_set_flex_flow(actions_wrapper, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_style_border_width(actions_wrapper, 0, 0);
|
||||
lv_obj_set_style_pad_all(actions_wrapper, 0, 0);
|
||||
lv_obj_set_style_pad_column(actions_wrapper, 4, 0);
|
||||
|
||||
auto* button = lv_button_create(actions_wrapper);
|
||||
lv_obj_add_event_cb(button, onDeviceButtonCallback, LV_EVENT_SHORT_CLICKED, this);
|
||||
lv_obj_set_user_data(button, device);
|
||||
auto* button_label = lv_label_create(button);
|
||||
lv_label_set_text(button_label, "Start");
|
||||
|
||||
DeviceRow row { .device = device, .button = button, .buttonLabel = button_label };
|
||||
|
||||
// Only devices backed by a persisted configuration (not devicetree-declared ones) can be deleted.
|
||||
size_t configurationIndex;
|
||||
if ((device->flags & DEVICE_FLAG_DYNAMIC) && findConfigurationIndexForDevice(device, configurationIndex)) {
|
||||
auto* delete_button = lv_button_create(actions_wrapper);
|
||||
lv_obj_add_event_cb(delete_button, onDeleteButtonCallback, LV_EVENT_SHORT_CLICKED, this);
|
||||
lv_obj_set_user_data(delete_button, device);
|
||||
auto* delete_label = lv_label_create(delete_button);
|
||||
lv_label_set_text(delete_label, LVGL_ICON_SHARED_DELETE);
|
||||
|
||||
row.hasConfiguration = true;
|
||||
row.configurationIndex = configurationIndex;
|
||||
}
|
||||
|
||||
deviceRows.push_back(row);
|
||||
// Finds the persisted configuration backing `device` (matched by its parent UART's name)
|
||||
// and returns its index into gps_settings_for_each_configuration()'s ordering - the handle
|
||||
// gps_settings_remove_configuration_at() needs to delete exactly this entry, even if another
|
||||
// entry happens to have identical field values.
|
||||
// Devicetree-declared GPS_TYPE devices have no such configuration and never match.
|
||||
bool findConfigurationIndexForDevice(Device* device, size_t& outIndex) {
|
||||
auto* parent = device_get_parent(device);
|
||||
if (parent == nullptr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rebuilds the device list. Only needs to run when the set of devices could've changed
|
||||
// (on show, and after returning from AddGpsApp) - button state itself is refreshed by the timer.
|
||||
void rebuildDeviceList() {
|
||||
lv_obj_clean(deviceListWrapper);
|
||||
deviceRows.clear();
|
||||
struct FindContext {
|
||||
const char* uartName;
|
||||
size_t* outIndex;
|
||||
bool found;
|
||||
} findContext = { parent->name, &outIndex, false };
|
||||
|
||||
device_for_each_of_type(&GPS_TYPE, this, [](Device* device, void* context) {
|
||||
static_cast<GpsSettingsApp*>(context)->createDeviceRow(device);
|
||||
return true;
|
||||
});
|
||||
gps_settings_for_each_configuration(&findContext, [](const GpsConfiguration* configuration, size_t index, void* untyped_context) {
|
||||
auto* ctx = static_cast<FindContext*>(untyped_context);
|
||||
if (!ctx->found && strcmp(configuration->uart_name, ctx->uartName) == 0) {
|
||||
*ctx->outIndex = index;
|
||||
ctx->found = true;
|
||||
}
|
||||
});
|
||||
|
||||
return findContext.found;
|
||||
}
|
||||
|
||||
void onDeleteButtonPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
auto* button = lv_event_get_target_obj(event);
|
||||
auto* device = static_cast<Device*>(lv_obj_get_user_data(button));
|
||||
|
||||
for (auto& row : ctx->deviceRows) {
|
||||
if (row.device == device && row.hasConfiguration) {
|
||||
ctx->pendingDeleteDevice = device;
|
||||
ctx->pendingDeleteIndex = row.configurationIndex;
|
||||
ctx->hasPendingDelete = true;
|
||||
alertdialog::start(ctx->appInstanceId, "Confirmation", std::string("Do you want to delete ") + device->name + "?", std::vector<std::string> { "Yes", "No" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void createDeviceRow(Context* ctx, Device* device) {
|
||||
auto* wrapper = lv_obj_create(ctx->deviceListWrapper);
|
||||
lv_obj_set_size(wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_border_width(wrapper, 0, 0);
|
||||
lv_obj_set_style_pad_all(wrapper, 0, 0);
|
||||
|
||||
auto* name_label = lv_label_create(wrapper);
|
||||
char model_name[64];
|
||||
if (gps_get_model_name(device, model_name, sizeof(model_name)) == ERROR_NONE) {
|
||||
lv_label_set_text(name_label, model_name);
|
||||
} else {
|
||||
lv_label_set_text(name_label, device->name);
|
||||
}
|
||||
|
||||
void updateDeviceStates() {
|
||||
lvgl_lock();
|
||||
for (const auto& row : deviceRows) {
|
||||
const char* text = "Start";
|
||||
bool enabled = true;
|
||||
auto* actions_wrapper = lv_obj_create(wrapper);
|
||||
lv_obj_set_size(actions_wrapper, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
||||
lv_obj_set_flex_flow(actions_wrapper, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_style_border_width(actions_wrapper, 0, 0);
|
||||
lv_obj_set_style_pad_all(actions_wrapper, 0, 0);
|
||||
lv_obj_set_style_pad_column(actions_wrapper, 4, 0);
|
||||
|
||||
if (device_is_ready(row.device)) {
|
||||
switch (gps_get_state(row.device)) {
|
||||
case GPS_STATE_PENDING_ON:
|
||||
text = "Starting...";
|
||||
enabled = false;
|
||||
break;
|
||||
case GPS_STATE_PENDING_OFF:
|
||||
text = "Stopping...";
|
||||
enabled = false;
|
||||
break;
|
||||
default:
|
||||
text = "Stop";
|
||||
enabled = true;
|
||||
break;
|
||||
auto* button = lv_button_create(actions_wrapper);
|
||||
lv_obj_add_event_cb(button, onDeviceButtonPressed, LV_EVENT_SHORT_CLICKED, ctx);
|
||||
lv_obj_set_user_data(button, device);
|
||||
auto* button_label = lv_label_create(button);
|
||||
lv_label_set_text(button_label, "Start");
|
||||
|
||||
DeviceRow row { .device = device, .button = button, .buttonLabel = button_label };
|
||||
|
||||
// Only devices backed by a persisted configuration (not devicetree-declared ones) can be deleted.
|
||||
size_t configurationIndex;
|
||||
if ((device->flags & DEVICE_FLAG_DYNAMIC) && findConfigurationIndexForDevice(device, configurationIndex)) {
|
||||
auto* delete_button = lv_button_create(actions_wrapper);
|
||||
lv_obj_add_event_cb(delete_button, onDeleteButtonPressed, LV_EVENT_SHORT_CLICKED, ctx);
|
||||
lv_obj_set_user_data(delete_button, device);
|
||||
auto* delete_label = lv_label_create(delete_button);
|
||||
lv_label_set_text(delete_label, LVGL_ICON_SHARED_DELETE);
|
||||
|
||||
row.hasConfiguration = true;
|
||||
row.configurationIndex = configurationIndex;
|
||||
}
|
||||
|
||||
ctx->deviceRows.push_back(row);
|
||||
}
|
||||
|
||||
// Rebuilds the device list. Only needs to run when the set of devices could've changed (on
|
||||
// creation, and after returning from AddGps) - button state itself is refreshed by the timer.
|
||||
void rebuildDeviceList(Context* ctx) {
|
||||
lv_obj_clean(ctx->deviceListWrapper);
|
||||
ctx->deviceRows.clear();
|
||||
|
||||
device_for_each_of_type(&GPS_TYPE, ctx, [](Device* device, void* context) {
|
||||
createDeviceRow(static_cast<Context*>(context), device);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
void updateDeviceStates(Context* ctx) {
|
||||
lvgl_lock();
|
||||
for (const auto& row : ctx->deviceRows) {
|
||||
const char* text = "Start";
|
||||
bool enabled = true;
|
||||
|
||||
if (device_is_ready(row.device)) {
|
||||
switch (gps_get_state(row.device)) {
|
||||
case GPS_STATE_PENDING_ON:
|
||||
text = "Starting...";
|
||||
enabled = false;
|
||||
break;
|
||||
case GPS_STATE_PENDING_OFF:
|
||||
text = "Stopping...";
|
||||
enabled = false;
|
||||
break;
|
||||
default:
|
||||
text = "Stop";
|
||||
enabled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
lv_label_set_text(row.buttonLabel, text);
|
||||
if (enabled) {
|
||||
lv_obj_remove_state(row.button, LV_STATE_DISABLED);
|
||||
} else {
|
||||
lv_obj_add_state(row.button, LV_STATE_DISABLED);
|
||||
}
|
||||
}
|
||||
lvgl_unlock();
|
||||
}
|
||||
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
uint8_t margin = (lvgl_get_ui_density() == LVGL_UI_DENSITY_COMPACT) ? 2 : 8;
|
||||
|
||||
auto* toolbar = lvgl_toolbar_create(parent, "GPS");
|
||||
// The global toolbar nav callback only knows how to stop old-model apps.
|
||||
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
|
||||
lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_PLUS, onAddGpsPressed, ctx);
|
||||
lv_obj_set_style_margin_bottom(toolbar, margin, LV_STATE_DEFAULT);
|
||||
|
||||
ctx->deviceListWrapper = lv_obj_create(parent);
|
||||
lv_obj_set_size(ctx->deviceListWrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_flex_flow(ctx->deviceListWrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_grow(ctx->deviceListWrapper, 1);
|
||||
lv_obj_set_style_border_width(ctx->deviceListWrapper, 0, 0);
|
||||
lv_obj_set_style_pad_hor(ctx->deviceListWrapper, margin, 0);
|
||||
lv_obj_set_style_pad_top(ctx->deviceListWrapper, 0, 0);
|
||||
lv_obj_set_style_pad_bottom(ctx->deviceListWrapper, margin, 0);
|
||||
lv_obj_set_style_pad_row(ctx->deviceListWrapper, margin, 0);
|
||||
|
||||
rebuildDeviceList(ctx);
|
||||
updateDeviceStates(ctx);
|
||||
}
|
||||
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
Context ctx {};
|
||||
ctx.appInstanceId = appInstanceId;
|
||||
|
||||
// Runs for this app instance's whole lifetime - there's no push notification for GPS
|
||||
// device state changes, so this is the only way this screen finds out about them.
|
||||
ctx.timer = std::make_unique<Timer>(Timer::Type::Periodic, seconds_to_ticks(1), [&ctx] {
|
||||
updateDeviceStates(&ctx);
|
||||
});
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
ctx.timer->start();
|
||||
|
||||
bool shouldClose = false;
|
||||
while (!shouldClose) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
switch (event.type) {
|
||||
case APP_EVENT_CLOSE:
|
||||
app_manager_finish(appInstanceId);
|
||||
shouldClose = true;
|
||||
break;
|
||||
case APP_EVENT_RESULT:
|
||||
if (ctx.hasPendingDelete) {
|
||||
ctx.hasPendingDelete = false;
|
||||
if (event.result.result == 0) { // 0 = Yes
|
||||
lvgl_lock();
|
||||
std::erase_if(ctx.deviceRows, [&ctx](const DeviceRow& row) {
|
||||
return row.device == ctx.pendingDeleteDevice;
|
||||
});
|
||||
lvgl_unlock();
|
||||
|
||||
gps_settings_remove_configuration_at(ctx.pendingDeleteIndex);
|
||||
ctx.pendingDeleteDevice = nullptr;
|
||||
|
||||
lvgl_lock();
|
||||
rebuildDeviceList(&ctx);
|
||||
lvgl_unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
lv_label_set_text(row.buttonLabel, text);
|
||||
if (enabled) {
|
||||
lv_obj_remove_state(row.button, LV_STATE_DISABLED);
|
||||
} else {
|
||||
lv_obj_add_state(row.button, LV_STATE_DISABLED);
|
||||
}
|
||||
app_manager_stop(event.result.launch_id);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
lvgl_unlock();
|
||||
}
|
||||
|
||||
public:
|
||||
ctx.timer->stop();
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
GpsSettingsApp() {
|
||||
// Runs while the screen is shown - there's no push notification for GPS device state
|
||||
// changes, so this is the only way this screen finds out about them.
|
||||
timer = std::make_unique<Timer>(Timer::Type::Periodic, seconds_to_ticks(1), [this] {
|
||||
updateDeviceStates();
|
||||
});
|
||||
}
|
||||
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override {
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
uint8_t margin = (lvgl_get_ui_density() == LVGL_UI_DENSITY_COMPACT) ? 2 : 8;
|
||||
|
||||
auto* toolbar = lvgl::toolbar_create(parent, app);
|
||||
lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_PLUS, onAddGpsCallback, this);
|
||||
lv_obj_set_style_margin_bottom(toolbar, margin, LV_STATE_DEFAULT);
|
||||
|
||||
deviceListWrapper = lv_obj_create(parent);
|
||||
lv_obj_set_size(deviceListWrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_flex_flow(deviceListWrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_grow(deviceListWrapper, 1);
|
||||
lv_obj_set_style_border_width(deviceListWrapper, 0, 0);
|
||||
lv_obj_set_style_pad_hor(deviceListWrapper, margin, 0);
|
||||
lv_obj_set_style_pad_top(deviceListWrapper, 0, 0);
|
||||
lv_obj_set_style_pad_bottom(deviceListWrapper, margin, 0);
|
||||
lv_obj_set_style_pad_row(deviceListWrapper, margin, 0);
|
||||
|
||||
rebuildDeviceList();
|
||||
|
||||
timer->start();
|
||||
updateDeviceStates();
|
||||
|
||||
// Only after deviceListWrapper is fully built: onResult() (Loader thread) checks
|
||||
// this before touching it, since it can run before or after this onShow() call.
|
||||
isShown = true;
|
||||
}
|
||||
|
||||
void onHide(AppContext& app) override {
|
||||
isShown = false;
|
||||
timer->stop();
|
||||
}
|
||||
|
||||
void onResult(AppContext&, LaunchId, Result result, std::unique_ptr<Bundle> bundle) override {
|
||||
if (!hasPendingDelete) {
|
||||
return;
|
||||
}
|
||||
hasPendingDelete = false;
|
||||
|
||||
if (result != Result::Ok || bundle == nullptr || alertdialog::getResultIndex(*bundle) != 0) { // 0 = Yes
|
||||
return;
|
||||
}
|
||||
|
||||
// This runs on the Loader thread, concurrently with the periodic timer callback
|
||||
// (updateDeviceStates(), timer daemon thread) and possibly with onShow() (GUI
|
||||
// thread). Take the same lock updateDeviceStates() uses and hold it across the
|
||||
// free below, so the timer can never observe pendingDeleteDevice as a dangling
|
||||
// pointer in deviceRows.
|
||||
lvgl_lock();
|
||||
// Drop the stale row unconditionally (cheap vector op, no LVGL calls) - this is
|
||||
// what keeps the timer safe regardless of whether onShow() has run yet this cycle.
|
||||
std::erase_if(deviceRows, [this](const DeviceRow& row) {
|
||||
return row.device == pendingDeleteDevice;
|
||||
});
|
||||
lvgl_unlock();
|
||||
|
||||
// gps_settings_remove_configuration_at() frees the underlying Device synchronously -
|
||||
// do this only after the dangling pointer is already out of deviceRows.
|
||||
gps_settings_remove_configuration_at(pendingDeleteIndex);
|
||||
pendingDeleteDevice = nullptr;
|
||||
|
||||
// Only safe to touch deviceListWrapper if onShow() already built it for this show
|
||||
// cycle - it may not have run yet, in which case it'll rebuild fresh (post-deletion,
|
||||
// deviceRows already correct) when it does.
|
||||
lvgl_lock();
|
||||
if (isShown) {
|
||||
rebuildDeviceList();
|
||||
}
|
||||
lvgl_unlock();
|
||||
}
|
||||
};
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "GpsSettings",
|
||||
.appName = "GPS",
|
||||
.appIcon = LVGL_ICON_SHARED_NAVIGATION,
|
||||
.appCategory = Category::Settings,
|
||||
.createApp = create<GpsSettingsApp>
|
||||
};
|
||||
|
||||
void start() {
|
||||
app::start(manifest.appId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "GpsSettings",
|
||||
.name = "GPS",
|
||||
.category = APP_CATEGORY_SETTINGS,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -2,78 +2,132 @@
|
||||
|
||||
#include <lvgl.h>
|
||||
|
||||
#include <lvgl/icons/shared.h>
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/grove.h>
|
||||
|
||||
#include <Tactility/Tactility.h>
|
||||
#include <Tactility/lvgl/Toolbar.h>
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
|
||||
namespace tt::app::grovesettings {
|
||||
|
||||
class GroveSettingsApp final : public App {
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
namespace {
|
||||
|
||||
struct Context {
|
||||
uint32_t appInstanceId;
|
||||
std::vector<::Device*> devices;
|
||||
|
||||
void collectDevices() {
|
||||
devices.clear();
|
||||
device_for_each_of_type(&GROVE_TYPE, &devices, [](auto* device, auto* context) {
|
||||
auto* vec = static_cast<std::vector<::Device*>*>(context);
|
||||
vec->push_back(device);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
static void onModeChanged(lv_event_t* e) {
|
||||
auto* device = static_cast<::Device*>(lv_event_get_user_data(e));
|
||||
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(e));
|
||||
auto mode = static_cast<GroveMode>(lv_dropdown_get_selected(dropdown));
|
||||
grove_set_mode(device, mode);
|
||||
}
|
||||
|
||||
public:
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override {
|
||||
collectDevices();
|
||||
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
lvgl::toolbar_create(parent, app);
|
||||
|
||||
auto* main_wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_width(main_wrapper, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(main_wrapper, 1);
|
||||
|
||||
for (auto* device : devices) {
|
||||
auto* row = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(row, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* label = lv_label_create(row);
|
||||
lv_label_set_text(label, device->name);
|
||||
lv_obj_align(label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
|
||||
auto* dropdown = lv_dropdown_create(row);
|
||||
lv_dropdown_set_options(dropdown, "Disabled\nUART\nI2C");
|
||||
lv_obj_align(dropdown, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
|
||||
GroveMode current = GROVE_MODE_DISABLED;
|
||||
grove_get_mode(device, ¤t);
|
||||
lv_dropdown_set_selected(dropdown, static_cast<uint32_t>(current));
|
||||
|
||||
lv_obj_add_event_cb(dropdown, onModeChanged, LV_EVENT_VALUE_CHANGED, device);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "GroveSettings",
|
||||
.appName = "Grove",
|
||||
.appIcon = LVGL_ICON_SHARED_CABLE,
|
||||
.appCategory = Category::Settings,
|
||||
.createApp = create<GroveSettingsApp>
|
||||
|
||||
void collectDevices(Context* ctx) {
|
||||
ctx->devices.clear();
|
||||
device_for_each_of_type(&GROVE_TYPE, &ctx->devices, [](auto* device, auto* context) {
|
||||
auto* vec = static_cast<std::vector<::Device*>*>(context);
|
||||
vec->push_back(device);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
void onModeChanged(lv_event_t* e) {
|
||||
auto* device = static_cast<::Device*>(lv_event_get_user_data(e));
|
||||
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(e));
|
||||
auto mode = static_cast<GroveMode>(lv_dropdown_get_selected(dropdown));
|
||||
grove_set_mode(device, mode);
|
||||
}
|
||||
|
||||
void onBackPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
|
||||
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
|
||||
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
|
||||
// deadlock against itself.
|
||||
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(ctx->appInstanceId, &closeEvent);
|
||||
}
|
||||
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
collectDevices(ctx);
|
||||
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* toolbar = lvgl_toolbar_create(parent, "Grove");
|
||||
// The global toolbar nav callback only knows how to stop old-model apps.
|
||||
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
|
||||
|
||||
auto* main_wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_width(main_wrapper, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(main_wrapper, 1);
|
||||
|
||||
for (auto* device : ctx->devices) {
|
||||
auto* row = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(row, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* label = lv_label_create(row);
|
||||
lv_label_set_text(label, device->name);
|
||||
lv_obj_align(label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
|
||||
auto* dropdown = lv_dropdown_create(row);
|
||||
lv_dropdown_set_options(dropdown, "Disabled\nUART\nI2C");
|
||||
lv_obj_align(dropdown, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
|
||||
GroveMode current = GROVE_MODE_DISABLED;
|
||||
grove_get_mode(device, ¤t);
|
||||
lv_dropdown_set_selected(dropdown, static_cast<uint32_t>(current));
|
||||
|
||||
lv_obj_add_event_cb(dropdown, onModeChanged, LV_EVENT_VALUE_CHANGED, device);
|
||||
}
|
||||
}
|
||||
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
Context ctx {};
|
||||
ctx.appInstanceId = appInstanceId;
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
|
||||
bool shouldClose = false;
|
||||
while (!shouldClose) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
switch (event.type) {
|
||||
case APP_EVENT_CLOSE:
|
||||
app_manager_finish(appInstanceId);
|
||||
shouldClose = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "GroveSettings",
|
||||
.name = "Grove",
|
||||
.category = APP_CATEGORY_SETTINGS,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@@ -1,31 +1,41 @@
|
||||
#include <Tactility/app/i2cscanner/I2cHelpers.h>
|
||||
#include <Tactility/app/i2cscanner/I2cScannerPrivate.h>
|
||||
#include <Tactility/LogMessages.h>
|
||||
#include <Tactility/Preferences.h>
|
||||
#include <Tactility/RecursiveMutex.h>
|
||||
#include <Tactility/Timer.h>
|
||||
#include <Tactility/app/AppContext.h>
|
||||
#include <Tactility/lvgl/Toolbar.h>
|
||||
#include <Tactility/service/loader/Loader.h>
|
||||
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <tactility/drivers/i2c_controller.h>
|
||||
#include <tactility/log.h>
|
||||
#include <tactility/paths.h>
|
||||
#include <tactility/preferences.h>
|
||||
|
||||
#include <cassert>
|
||||
#include <format>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <lvgl/lvgl.h>
|
||||
#include <lvgl/icons/shared.h>
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
|
||||
namespace tt::app::i2cscanner {
|
||||
|
||||
extern const AppManifest manifest;
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
class I2cScannerApp final : public App {
|
||||
namespace {
|
||||
|
||||
static constexpr auto* TAG = "I2cScanner";
|
||||
constexpr auto* TAG = "I2cScanner";
|
||||
|
||||
static constexpr auto* START_SCAN_TEXT = "Scan";
|
||||
static constexpr auto* STOP_SCAN_TEXT = "Stop scan";
|
||||
constexpr auto* START_SCAN_TEXT = "Scan";
|
||||
constexpr auto* STOP_SCAN_TEXT = "Stop scan";
|
||||
|
||||
struct Context {
|
||||
uint32_t appInstanceId;
|
||||
|
||||
// Core
|
||||
RecursiveMutex mutex;
|
||||
@@ -38,68 +48,275 @@ class I2cScannerApp final : public App {
|
||||
lv_obj_t* scanButtonLabelWidget = nullptr;
|
||||
lv_obj_t* portDropdownWidget = nullptr;
|
||||
lv_obj_t* scanListWidget = nullptr;
|
||||
|
||||
static void setLastBusIndex(int32_t index);
|
||||
static int32_t getLastBusIndex();
|
||||
|
||||
void selectBus(int32_t selected);
|
||||
|
||||
static void onSelectBusCallback(lv_event_t* event);
|
||||
static void onPressScanCallback(lv_event_t* event);
|
||||
|
||||
void onSelectBus(lv_event_t* event);
|
||||
void onPressScan(lv_event_t* event);
|
||||
void onScanTimer();
|
||||
|
||||
bool shouldStopScanTimer();
|
||||
bool getPort(struct Device** outPort);
|
||||
bool addAddressToList(uint8_t address);
|
||||
bool hasScanThread();
|
||||
void startScanning();
|
||||
void stopScanning();
|
||||
|
||||
void updateViews();
|
||||
void updateViewsSafely();
|
||||
|
||||
void onScanTimerFinished();
|
||||
|
||||
public:
|
||||
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override;
|
||||
void onHide(AppContext& app) override;
|
||||
};
|
||||
|
||||
/** Returns the app data if the app is active. Note that this could clash if the same app is started twice and a background thread is slow. */
|
||||
std::shared_ptr<I2cScannerApp> optApp() {
|
||||
auto appContext = getCurrentAppContext();
|
||||
if (appContext != nullptr && appContext->getManifest().appId == manifest.appId) {
|
||||
return std::static_pointer_cast<I2cScannerApp>(appContext->getApp());
|
||||
} else {
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
#define PREFERENCES_BUS_INDEX_KEY "bus"
|
||||
|
||||
void I2cScannerApp::setLastBusIndex(int32_t index) {
|
||||
auto prefs = Preferences("i2c_scanner");
|
||||
prefs.putInt32(PREFERENCES_BUS_INDEX_KEY, index);
|
||||
bool getPreferencesPath(std::string& outPath) {
|
||||
char root[128];
|
||||
if (paths_get_user_data_path(root, sizeof(root)) != ERROR_NONE) {
|
||||
return false;
|
||||
}
|
||||
outPath = std::string(root) + "/i2c_scanner.properties";
|
||||
return true;
|
||||
}
|
||||
|
||||
int32_t I2cScannerApp::getLastBusIndex() {
|
||||
auto prefs = Preferences("i2c_scanner");
|
||||
void setLastBusIndex(int32_t index) {
|
||||
std::string path;
|
||||
if (!getPreferencesPath(path)) {
|
||||
return;
|
||||
}
|
||||
Preferences* prefs = preferences_open(path.c_str());
|
||||
if (prefs == nullptr) {
|
||||
return;
|
||||
}
|
||||
preferences_put_int32(prefs, PREFERENCES_BUS_INDEX_KEY, index);
|
||||
preferences_close(prefs);
|
||||
}
|
||||
|
||||
int32_t getLastBusIndex() {
|
||||
std::string path;
|
||||
if (!getPreferencesPath(path)) {
|
||||
return 0;
|
||||
}
|
||||
Preferences* prefs = preferences_open(path.c_str());
|
||||
if (prefs == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
int32_t index = 0;
|
||||
prefs.optInt32(PREFERENCES_BUS_INDEX_KEY, index);
|
||||
preferences_opt_int32(prefs, PREFERENCES_BUS_INDEX_KEY, &index);
|
||||
preferences_close(prefs);
|
||||
return index;
|
||||
}
|
||||
|
||||
// region Lifecycle
|
||||
bool getPort(Context* ctx, struct Device** outPort) {
|
||||
if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) {
|
||||
*outPort = ctx->portDevice;
|
||||
ctx->mutex.unlock();
|
||||
return true;
|
||||
} else {
|
||||
LOG_W(TAG, "Mutex acquisition timeout (%s)", "getPort");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool addAddressToList(Context* ctx, uint8_t address) {
|
||||
if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) {
|
||||
ctx->scannedAddresses.push_back(address);
|
||||
ctx->mutex.unlock();
|
||||
return true;
|
||||
} else {
|
||||
LOG_W(TAG, "Mutex acquisition timeout (%s)", "addAddressToList");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool shouldStopScanTimer(Context* ctx) {
|
||||
if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) {
|
||||
bool is_scanning = ctx->scanState == ScanStateScanning;
|
||||
ctx->mutex.unlock();
|
||||
return !is_scanning;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
void updateViews(Context* ctx) {
|
||||
if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) {
|
||||
if (ctx->scanState == ScanStateScanning) {
|
||||
lv_label_set_text(ctx->scanButtonLabelWidget, STOP_SCAN_TEXT);
|
||||
lv_obj_remove_flag(ctx->portDropdownWidget, LV_OBJ_FLAG_CLICKABLE);
|
||||
} else {
|
||||
lv_label_set_text(ctx->scanButtonLabelWidget, START_SCAN_TEXT);
|
||||
lv_obj_add_flag(ctx->portDropdownWidget, LV_OBJ_FLAG_CLICKABLE);
|
||||
}
|
||||
|
||||
lv_obj_clean(ctx->scanListWidget);
|
||||
if (ctx->scanState == ScanStateStopped) {
|
||||
lv_obj_remove_flag(ctx->scanListWidget, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
if (!ctx->scannedAddresses.empty()) {
|
||||
for (auto address: ctx->scannedAddresses) {
|
||||
std::string address_text = getAddressText(address);
|
||||
lv_list_add_text(ctx->scanListWidget, address_text.c_str());
|
||||
}
|
||||
} else {
|
||||
lv_list_add_text(ctx->scanListWidget, "No devices found");
|
||||
}
|
||||
} else {
|
||||
lv_obj_add_flag(ctx->scanListWidget, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
|
||||
ctx->mutex.unlock();
|
||||
} else {
|
||||
LOG_W(TAG, "Mutex acquisition timeout (%s)", "updateViews");
|
||||
}
|
||||
}
|
||||
|
||||
void updateViewsSafely(Context* ctx) {
|
||||
lvgl_lock();
|
||||
updateViews(ctx);
|
||||
lvgl_unlock();
|
||||
}
|
||||
|
||||
void onScanTimerFinished(Context* ctx) {
|
||||
if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) {
|
||||
if (ctx->scanState == ScanStateScanning) {
|
||||
ctx->scanState = ScanStateStopped;
|
||||
}
|
||||
ctx->mutex.unlock();
|
||||
|
||||
updateViewsSafely(ctx);
|
||||
} else {
|
||||
LOG_W(TAG, "Mutex acquisition timeout (%s)", "onScanTimerFinished");
|
||||
}
|
||||
}
|
||||
|
||||
void onScanTimer(Context* ctx) {
|
||||
LOG_I(TAG, "Scan thread started");
|
||||
|
||||
Device* safe_port;
|
||||
if (!getPort(ctx, &safe_port)) {
|
||||
LOG_E(TAG, "Failed to get I2C port");
|
||||
onScanTimerFinished(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!device_is_ready(safe_port)) {
|
||||
LOG_E(TAG, "I2C port not started");
|
||||
onScanTimerFinished(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
for (uint8_t address = 1; address < 128; ++address) {
|
||||
if (i2c_controller_has_device_at_address(safe_port, address, 10 / portTICK_PERIOD_MS) == ERROR_NONE) {
|
||||
LOG_I(TAG, "Found device at address 0x%02X", address);
|
||||
if (!shouldStopScanTimer(ctx)) {
|
||||
addAddressToList(ctx, address);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldStopScanTimer(ctx)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
LOG_I(TAG, "Scan thread finalizing");
|
||||
|
||||
onScanTimerFinished(ctx);
|
||||
|
||||
LOG_I(TAG, "Scan timer done");
|
||||
}
|
||||
|
||||
bool hasScanThread(Context* ctx) {
|
||||
bool has_thread;
|
||||
if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) {
|
||||
has_thread = ctx->scanTimer != nullptr;
|
||||
ctx->mutex.unlock();
|
||||
return has_thread;
|
||||
} else {
|
||||
// Unsafe way
|
||||
LOG_W(TAG, "Mutex acquisition timeout (%s)", "hasScanTimer");
|
||||
return ctx->scanTimer != nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void stopScanning(Context* ctx) {
|
||||
if (ctx->mutex.lock(250 / portTICK_PERIOD_MS)) {
|
||||
assert(ctx->scanTimer != nullptr);
|
||||
ctx->scanState = ScanStateStopped;
|
||||
ctx->mutex.unlock();
|
||||
} else {
|
||||
LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
void startScanning(Context* ctx) {
|
||||
if (hasScanThread(ctx)) {
|
||||
stopScanning(ctx);
|
||||
}
|
||||
|
||||
if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) {
|
||||
ctx->scannedAddresses.clear();
|
||||
|
||||
lv_obj_add_flag(ctx->scanListWidget, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_clean(ctx->scanListWidget);
|
||||
|
||||
ctx->scanState = ScanStateScanning;
|
||||
ctx->scanTimer = std::make_unique<Timer>(Timer::Type::Once, 10, [ctx]{
|
||||
onScanTimer(ctx);
|
||||
});
|
||||
ctx->scanTimer->start();
|
||||
ctx->mutex.unlock();
|
||||
} else {
|
||||
LOG_W(TAG, "Mutex acquisition timeout (%s)", "startScanning");
|
||||
}
|
||||
}
|
||||
|
||||
void selectBus(Context* ctx, int32_t selected) {
|
||||
struct Device* found_device;
|
||||
if (!getActivePortAtIndex(selected, &found_device)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) {
|
||||
ctx->scannedAddresses.clear();
|
||||
ctx->portDevice = found_device;
|
||||
ctx->scanState = ScanStateInitial;
|
||||
ctx->mutex.unlock();
|
||||
}
|
||||
|
||||
LOG_I(TAG, "Selected %d", (int)selected);
|
||||
setLastBusIndex(selected);
|
||||
|
||||
startScanning(ctx);
|
||||
|
||||
updateViews(ctx);
|
||||
}
|
||||
|
||||
// region Callbacks
|
||||
|
||||
void onBackPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
|
||||
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
|
||||
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
|
||||
// deadlock against itself.
|
||||
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(ctx->appInstanceId, &closeEvent);
|
||||
}
|
||||
|
||||
void onSelectBus(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
uint32_t selected = lv_dropdown_get_selected(dropdown);
|
||||
selectBus(ctx, selected);
|
||||
}
|
||||
|
||||
void onPressScan(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
if (ctx->scanState == ScanStateScanning) {
|
||||
stopScanning(ctx);
|
||||
} else {
|
||||
startScanning(ctx);
|
||||
}
|
||||
updateViews(ctx);
|
||||
}
|
||||
|
||||
// endregion Callbacks
|
||||
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
|
||||
void I2cScannerApp::onShow(AppContext& app, lv_obj_t* parent) {
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
lvgl::toolbar_create(parent, app);
|
||||
auto* toolbar = lvgl_toolbar_create(parent, "I2C Scanner");
|
||||
// The global toolbar nav callback only knows how to stop old-model apps.
|
||||
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
|
||||
|
||||
auto* main_wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
@@ -115,286 +332,103 @@ void I2cScannerApp::onShow(AppContext& app, lv_obj_t* parent) {
|
||||
auto* scan_button = lv_button_create(wrapper);
|
||||
lv_obj_set_width(scan_button, LV_PCT(48));
|
||||
lv_obj_align(scan_button, LV_ALIGN_TOP_LEFT, 0, 1); // Shift 1 pixel to align with selection box
|
||||
lv_obj_add_event_cb(scan_button, onPressScanCallback, LV_EVENT_SHORT_CLICKED, this);
|
||||
lv_obj_add_event_cb(scan_button, onPressScan, LV_EVENT_SHORT_CLICKED, ctx);
|
||||
auto* scan_button_label = lv_label_create(scan_button);
|
||||
lv_obj_align(scan_button_label, LV_ALIGN_CENTER, 0, 0);
|
||||
lv_label_set_text(scan_button_label, START_SCAN_TEXT);
|
||||
scanButtonLabelWidget = scan_button_label;
|
||||
ctx->scanButtonLabelWidget = scan_button_label;
|
||||
|
||||
auto* port_dropdown = lv_dropdown_create(wrapper);
|
||||
std::string dropdown_items = getPortNamesForDropdown();
|
||||
lv_dropdown_set_options(port_dropdown, dropdown_items.c_str());
|
||||
lv_obj_set_width(port_dropdown, LV_PCT(48));
|
||||
lv_obj_align(port_dropdown, LV_ALIGN_TOP_RIGHT, 0, 0);
|
||||
lv_obj_add_event_cb(port_dropdown, onSelectBusCallback, LV_EVENT_VALUE_CHANGED, this);
|
||||
lv_obj_add_event_cb(port_dropdown, onSelectBus, LV_EVENT_VALUE_CHANGED, ctx);
|
||||
auto selected_bus = getLastBusIndex();
|
||||
lv_dropdown_set_selected(port_dropdown, selected_bus);
|
||||
portDropdownWidget = port_dropdown;
|
||||
ctx->portDropdownWidget = port_dropdown;
|
||||
|
||||
auto* scan_list = lv_list_create(main_wrapper);
|
||||
lv_obj_set_style_margin_top(scan_list, 8, 0);
|
||||
lv_obj_set_width(scan_list, LV_PCT(100));
|
||||
lv_obj_set_height(scan_list, LV_SIZE_CONTENT);
|
||||
lv_obj_add_flag(scan_list, LV_OBJ_FLAG_HIDDEN);
|
||||
scanListWidget = scan_list;
|
||||
ctx->scanListWidget = scan_list;
|
||||
|
||||
struct Device* dummy;
|
||||
if (getActivePortAtIndex(selected_bus, &dummy)) {
|
||||
selectBus(selected_bus);
|
||||
selectBus(ctx, selected_bus);
|
||||
} else if (getActivePortAtIndex(0, &dummy)) {
|
||||
lv_dropdown_set_selected(port_dropdown, 0);
|
||||
selectBus(0);
|
||||
selectBus(ctx, 0);
|
||||
}
|
||||
}
|
||||
|
||||
void I2cScannerApp::onHide(AppContext& app) {
|
||||
// Mirrors the old model's onHide(): stop any in-flight scan before this app's task exits
|
||||
// (APP_EVENT_CLOSE).
|
||||
void stopScanningIfRunning(Context* ctx) {
|
||||
bool isRunning = false;
|
||||
if (mutex.lock(250 / portTICK_PERIOD_MS)) {
|
||||
auto* timer = scanTimer.get();
|
||||
if (ctx->mutex.lock(250 / portTICK_PERIOD_MS)) {
|
||||
auto* timer = ctx->scanTimer.get();
|
||||
if (timer != nullptr) {
|
||||
isRunning = timer->isRunning();
|
||||
}
|
||||
mutex.unlock();
|
||||
ctx->mutex.unlock();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
stopScanning();
|
||||
stopScanning(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
// endregion Lifecycle
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
Context ctx;
|
||||
ctx.appInstanceId = appInstanceId;
|
||||
|
||||
// region Callbacks
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
void I2cScannerApp::onSelectBusCallback(lv_event_t* event) {
|
||||
auto* app = (I2cScannerApp*)lv_event_get_user_data(event);
|
||||
if (app != nullptr) {
|
||||
app->onSelectBus(event);
|
||||
}
|
||||
}
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
|
||||
void I2cScannerApp::onPressScanCallback(lv_event_t* event) {
|
||||
auto* app = (I2cScannerApp*)lv_event_get_user_data(event);
|
||||
if (app != nullptr) {
|
||||
app->onPressScan(event);
|
||||
}
|
||||
}
|
||||
|
||||
// endregion Callbacks
|
||||
|
||||
bool I2cScannerApp::getPort(struct Device** outPort) {
|
||||
if (mutex.lock(100 / portTICK_PERIOD_MS)) {
|
||||
*outPort = this->portDevice;
|
||||
mutex.unlock();
|
||||
return true;
|
||||
} else {
|
||||
LOG_W(TAG, "Mutex acquisition timeout (%s)", "getPort");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool I2cScannerApp::addAddressToList(uint8_t address) {
|
||||
if (mutex.lock(100 / portTICK_PERIOD_MS)) {
|
||||
scannedAddresses.push_back(address);
|
||||
mutex.unlock();
|
||||
return true;
|
||||
} else {
|
||||
LOG_W(TAG, "Mutex acquisition timeout (%s)", "addAddressToList");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool I2cScannerApp::shouldStopScanTimer() {
|
||||
if (mutex.lock(100 / portTICK_PERIOD_MS)) {
|
||||
bool is_scanning = scanState == ScanStateScanning;
|
||||
mutex.unlock();
|
||||
return !is_scanning;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
void I2cScannerApp::onScanTimer() {
|
||||
LOG_I(TAG, "Scan thread started");
|
||||
|
||||
Device* safe_port;
|
||||
if (!getPort(&safe_port)) {
|
||||
LOG_E(TAG, "Failed to get I2C port");
|
||||
onScanTimerFinished();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!device_is_ready(safe_port)) {
|
||||
LOG_E(TAG, "I2C port not started");
|
||||
onScanTimerFinished();
|
||||
return;
|
||||
}
|
||||
|
||||
for (uint8_t address = 1; address < 128; ++address) {
|
||||
if (i2c_controller_has_device_at_address(safe_port, address, 10 / portTICK_PERIOD_MS) == ERROR_NONE) {
|
||||
LOG_I(TAG, "Found device at address 0x%02X", address);
|
||||
if (!shouldStopScanTimer()) {
|
||||
addAddressToList(address);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldStopScanTimer()) {
|
||||
bool shouldClose = false;
|
||||
while (!shouldClose) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
LOG_I(TAG, "Scan thread finalizing");
|
||||
|
||||
onScanTimerFinished();
|
||||
|
||||
LOG_I(TAG, "Scan timer done");
|
||||
}
|
||||
|
||||
bool I2cScannerApp::hasScanThread() {
|
||||
bool has_thread;
|
||||
if (mutex.lock(100 / portTICK_PERIOD_MS)) {
|
||||
has_thread = scanTimer != nullptr;
|
||||
mutex.unlock();
|
||||
return has_thread;
|
||||
} else {
|
||||
// Unsafe way
|
||||
LOG_W(TAG, "Mutex acquisition timeout (%s)", "hasScanTimer");
|
||||
return scanTimer != nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void I2cScannerApp::startScanning() {
|
||||
if (hasScanThread()) {
|
||||
stopScanning();
|
||||
}
|
||||
|
||||
if (mutex.lock(100 / portTICK_PERIOD_MS)) {
|
||||
scannedAddresses.clear();
|
||||
|
||||
lv_obj_add_flag(scanListWidget, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_clean(scanListWidget);
|
||||
|
||||
scanState = ScanStateScanning;
|
||||
scanTimer = std::make_unique<Timer>(Timer::Type::Once, 10, [this]{
|
||||
onScanTimer();
|
||||
});
|
||||
scanTimer->start();
|
||||
mutex.unlock();
|
||||
} else {
|
||||
LOG_W(TAG, "Mutex acquisition timeout (%s)", "startScanning");
|
||||
}
|
||||
}
|
||||
void I2cScannerApp::stopScanning() {
|
||||
if (mutex.lock(250 / portTICK_PERIOD_MS)) {
|
||||
assert(scanTimer != nullptr);
|
||||
scanState = ScanStateStopped;
|
||||
mutex.unlock();
|
||||
} else {
|
||||
LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
void I2cScannerApp::onSelectBus(lv_event_t* event) {
|
||||
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
uint32_t selected = lv_dropdown_get_selected(dropdown);
|
||||
selectBus(selected);
|
||||
}
|
||||
|
||||
void I2cScannerApp::selectBus(int32_t selected) {
|
||||
struct Device* found_device;
|
||||
if (!getActivePortAtIndex(selected, &found_device)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mutex.lock(100 / portTICK_PERIOD_MS)) {
|
||||
scannedAddresses.clear();
|
||||
portDevice = found_device;
|
||||
scanState = ScanStateInitial;
|
||||
mutex.unlock();
|
||||
}
|
||||
|
||||
LOG_I(TAG, "Selected %d", (int)selected);
|
||||
setLastBusIndex(selected);
|
||||
|
||||
startScanning();
|
||||
|
||||
updateViews();
|
||||
}
|
||||
|
||||
void I2cScannerApp::onPressScan(lv_event_t* event) {
|
||||
if (scanState == ScanStateScanning) {
|
||||
stopScanning();
|
||||
} else {
|
||||
startScanning();
|
||||
}
|
||||
updateViews();
|
||||
}
|
||||
|
||||
void I2cScannerApp::updateViews() {
|
||||
if (mutex.lock(100 / portTICK_PERIOD_MS)) {
|
||||
if (scanState == ScanStateScanning) {
|
||||
lv_label_set_text(scanButtonLabelWidget, STOP_SCAN_TEXT);
|
||||
lv_obj_remove_flag(portDropdownWidget, LV_OBJ_FLAG_CLICKABLE);
|
||||
} else {
|
||||
lv_label_set_text(scanButtonLabelWidget, START_SCAN_TEXT);
|
||||
lv_obj_add_flag(portDropdownWidget, LV_OBJ_FLAG_CLICKABLE);
|
||||
switch (event.type) {
|
||||
case APP_EVENT_CLOSE:
|
||||
stopScanningIfRunning(&ctx);
|
||||
app_manager_finish(appInstanceId);
|
||||
shouldClose = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
lv_obj_clean(scanListWidget);
|
||||
if (scanState == ScanStateStopped) {
|
||||
lv_obj_remove_flag(scanListWidget, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
if (!scannedAddresses.empty()) {
|
||||
for (auto address: scannedAddresses) {
|
||||
std::string address_text = getAddressText(address);
|
||||
lv_list_add_text(scanListWidget, address_text.c_str());
|
||||
}
|
||||
} else {
|
||||
lv_list_add_text(scanListWidget, "No devices found");
|
||||
}
|
||||
} else {
|
||||
lv_obj_add_flag(scanListWidget, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
|
||||
mutex.unlock();
|
||||
} else {
|
||||
LOG_W(TAG, "Mutex acquisition timeout (%s)", "updateViews");
|
||||
}
|
||||
}
|
||||
|
||||
void I2cScannerApp::updateViewsSafely() {
|
||||
lvgl_lock();
|
||||
updateViews();
|
||||
lvgl_unlock();
|
||||
}
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
void I2cScannerApp::onScanTimerFinished() {
|
||||
if (mutex.lock(100 / portTICK_PERIOD_MS)) {
|
||||
if (scanState == ScanStateScanning) {
|
||||
scanState = ScanStateStopped;
|
||||
}
|
||||
mutex.unlock();
|
||||
|
||||
updateViewsSafely();
|
||||
} else {
|
||||
LOG_W(TAG, "Mutex acquisition timeout (%s)", "onScanTimerFinished");
|
||||
}
|
||||
}
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "I2cScanner",
|
||||
.appName = "I2C Scanner",
|
||||
.appIcon = LVGL_ICON_SHARED_SEARCH,
|
||||
.appCategory = Category::System,
|
||||
.createApp = create<I2cScannerApp>
|
||||
};
|
||||
|
||||
LaunchId start() {
|
||||
return app::start(manifest.appId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "I2cScanner",
|
||||
.name = "I2C Scanner",
|
||||
.category = APP_CATEGORY_SYSTEM,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
|
||||
};
|
||||
|
||||
uint32_t start() {
|
||||
uint32_t instanceId = 0;
|
||||
app_manager_start(manifest.id, &instanceId);
|
||||
return instanceId;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -1,76 +1,136 @@
|
||||
#include <Tactility/lvgl/Lvgl.h>
|
||||
#include <Tactility/lvgl/Style.h>
|
||||
#include <Tactility/lvgl/Toolbar.h>
|
||||
#include <Tactility/service/loader/Loader.h>
|
||||
#include <Tactility/StringUtils.h>
|
||||
#include <tactility/check.h>
|
||||
#include <tactility/log.h>
|
||||
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
#include <lvgl.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace tt::app::imageviewer {
|
||||
|
||||
extern const AppManifest manifest;
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
constexpr auto* TAG = "ImageViewer";
|
||||
constexpr auto* IMAGE_VIEWER_FILE_ARGUMENT = "file";
|
||||
|
||||
class ImageViewerApp final : public App {
|
||||
namespace {
|
||||
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override {
|
||||
auto wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_size(wrapper, LV_PCT(100), LV_PCT(100));
|
||||
lv_obj_set_style_border_width(wrapper, 0, 0);
|
||||
lv_obj_set_style_pad_all(wrapper, 0, 0);
|
||||
lv_obj_set_style_pad_gap(wrapper, 0, 0);
|
||||
struct Context {
|
||||
uint32_t appInstanceId;
|
||||
std::string filePath;
|
||||
};
|
||||
|
||||
auto toolbar = lvgl::toolbar_create(wrapper, app);
|
||||
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||
|
||||
auto* image_wrapper = lv_obj_create(wrapper);
|
||||
lv_obj_align_to(image_wrapper, toolbar, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 0);
|
||||
lv_obj_set_width(image_wrapper, LV_PCT(100));
|
||||
auto parent_height = lv_obj_get_height(wrapper);
|
||||
auto toolbar_height = lv_obj_get_height(toolbar);
|
||||
lv_obj_set_height(image_wrapper, parent_height - toolbar_height);
|
||||
lv_obj_set_flex_flow(image_wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(image_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_all(image_wrapper, 0, 0);
|
||||
lv_obj_set_style_pad_gap(image_wrapper, 0, 0);
|
||||
lvgl::obj_set_style_bg_invisible(image_wrapper);
|
||||
void onBackPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
|
||||
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
|
||||
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
|
||||
// deadlock against itself.
|
||||
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(ctx->appInstanceId, &closeEvent);
|
||||
}
|
||||
|
||||
auto* image = lv_image_create(image_wrapper);
|
||||
lv_obj_align(image, LV_ALIGN_CENTER, 0, 0);
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
|
||||
auto* file_label = lv_label_create(wrapper);
|
||||
lv_obj_align_to(file_label, wrapper, LV_ALIGN_BOTTOM_LEFT, 0, 0);
|
||||
auto* wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_size(wrapper, LV_PCT(100), LV_PCT(100));
|
||||
lv_obj_set_style_border_width(wrapper, 0, 0);
|
||||
lv_obj_set_style_pad_all(wrapper, 0, 0);
|
||||
lv_obj_set_style_pad_gap(wrapper, 0, 0);
|
||||
|
||||
std::shared_ptr<const Bundle> bundle = app.getParameters();
|
||||
check(bundle != nullptr, "Parameters not set");
|
||||
std::string file_argument;
|
||||
if (bundle->optString(IMAGE_VIEWER_FILE_ARGUMENT, file_argument)) {
|
||||
std::string prefixed_path = lvgl::PATH_PREFIX + file_argument;
|
||||
LOG_I(TAG, "Opening %s", prefixed_path.c_str());
|
||||
lv_img_set_src(image, prefixed_path.c_str());
|
||||
auto path = string::getLastPathSegment(file_argument);
|
||||
lv_label_set_text(file_label, path.c_str());
|
||||
} else {
|
||||
lv_label_set_text(file_label, "File not found");
|
||||
auto* toolbar = lvgl_toolbar_create(wrapper, "Image Viewer");
|
||||
// The global toolbar nav callback only knows how to stop old-model apps.
|
||||
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
|
||||
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||
|
||||
auto* image_wrapper = lv_obj_create(wrapper);
|
||||
lv_obj_align_to(image_wrapper, toolbar, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 0);
|
||||
lv_obj_set_width(image_wrapper, LV_PCT(100));
|
||||
auto parent_height = lv_obj_get_height(wrapper);
|
||||
auto toolbar_height = lv_obj_get_height(toolbar);
|
||||
lv_obj_set_height(image_wrapper, parent_height - toolbar_height);
|
||||
lv_obj_set_flex_flow(image_wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(image_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_all(image_wrapper, 0, 0);
|
||||
lv_obj_set_style_pad_gap(image_wrapper, 0, 0);
|
||||
lvgl::obj_set_style_bg_invisible(image_wrapper);
|
||||
|
||||
auto* image = lv_image_create(image_wrapper);
|
||||
lv_obj_align(image, LV_ALIGN_CENTER, 0, 0);
|
||||
|
||||
auto* file_label = lv_label_create(wrapper);
|
||||
lv_obj_align_to(file_label, wrapper, LV_ALIGN_BOTTOM_LEFT, 0, 0);
|
||||
|
||||
if (!ctx->filePath.empty()) {
|
||||
std::string prefixed_path = lvgl::PATH_PREFIX + ctx->filePath;
|
||||
LOG_I(TAG, "Opening %s", prefixed_path.c_str());
|
||||
lv_img_set_src(image, prefixed_path.c_str());
|
||||
auto path = string::getLastPathSegment(ctx->filePath);
|
||||
lv_label_set_text(file_label, path.c_str());
|
||||
} else {
|
||||
lv_label_set_text(file_label, "File not found");
|
||||
}
|
||||
}
|
||||
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
check(argc > 0, "Parameters not set");
|
||||
|
||||
Context ctx {};
|
||||
ctx.appInstanceId = appInstanceId;
|
||||
ctx.filePath = argv[0];
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
|
||||
bool shouldClose = false;
|
||||
while (!shouldClose) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
switch (event.type) {
|
||||
case APP_EVENT_CLOSE:
|
||||
app_manager_finish(appInstanceId);
|
||||
shouldClose = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "ImageViewer",
|
||||
.appName = "Image Viewer",
|
||||
.appCategory = Category::System,
|
||||
.appFlags = AppManifest::Flags::Hidden,
|
||||
.createApp = create<ImageViewerApp>
|
||||
};
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
LaunchId start(const std::string& file) {
|
||||
auto parameters = std::make_shared<Bundle>();
|
||||
parameters->putString(IMAGE_VIEWER_FILE_ARGUMENT, file);
|
||||
return app::start(manifest.appId, parameters);
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void start(const std::string& file) {
|
||||
const char* argv[] = { file.c_str() };
|
||||
uint32_t instanceId = 0;
|
||||
app_manager_start_with_parameters(manifest.id, 1, argv, &instanceId);
|
||||
}
|
||||
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "ImageViewer",
|
||||
.name = "Image Viewer",
|
||||
.category = APP_CATEGORY_SYSTEM,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
|
||||
.flags = APP_MANIFEST_FLAG_HIDDEN,
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -1,128 +1,158 @@
|
||||
#include <Tactility/app/inputdialog/InputDialog.h>
|
||||
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
#include <Tactility/service/loader/Loader.h>
|
||||
#include <Tactility/TactilityCore.h>
|
||||
#include <tactility/log.h>
|
||||
|
||||
#include <lvgl.h>
|
||||
|
||||
namespace tt::app::inputdialog {
|
||||
|
||||
constexpr auto* PARAMETER_BUNDLE_KEY_TITLE = "title";
|
||||
constexpr auto* PARAMETER_BUNDLE_KEY_MESSAGE = "message";
|
||||
constexpr auto* PARAMETER_BUNDLE_KEY_PREFILLED = "prefilled";
|
||||
constexpr auto* RESULT_BUNDLE_KEY_RESULT = "result";
|
||||
|
||||
constexpr auto* DEFAULT_TITLE = "Input";
|
||||
|
||||
constexpr auto* TAG = "InputDialog";
|
||||
|
||||
extern const AppManifest manifest;
|
||||
class InputDialogApp;
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
LaunchId start(const std::string& title, const std::string& message, const std::string& prefilled) {
|
||||
auto bundle = std::make_shared<Bundle>();
|
||||
bundle->putString(PARAMETER_BUNDLE_KEY_TITLE, title);
|
||||
bundle->putString(PARAMETER_BUNDLE_KEY_MESSAGE, message);
|
||||
bundle->putString(PARAMETER_BUNDLE_KEY_PREFILLED, prefilled);
|
||||
return app::start(manifest.appId, bundle);
|
||||
namespace {
|
||||
|
||||
struct Context {
|
||||
uint32_t appInstanceId;
|
||||
// Set once in appMain() from its own argc/argv parameters, read by createWidgets() - see
|
||||
// AlertDialog.cpp's Context::argc/argv for why this is safe without a lock.
|
||||
int argc = 0;
|
||||
char** argv = nullptr;
|
||||
// The eventual appMain() return value - see AlertDialog.cpp's Context::result for why this
|
||||
// is a plain (non-atomic) field safely shared between the LVGL thread (writer, before
|
||||
// emitting APP_EVENT_CLOSE) and this dialog's own thread (reader, after waking from it).
|
||||
int32_t result = 1; // Cancelled - safety-net default if closed without pressing a button
|
||||
};
|
||||
|
||||
struct ButtonContext {
|
||||
Context* ctx;
|
||||
/** Non-null for OK (read at press time), NULL for Cancel. */
|
||||
lv_obj_t* textarea;
|
||||
};
|
||||
|
||||
// The last text entered via OK. Static rather than per-instance: simple, and in practice only
|
||||
// one InputDialog is ever open at a time. Written on the LVGL thread (onButtonPressed(), before
|
||||
// emitting APP_EVENT_CLOSE); read by the parent via getLastText() after receiving that event -
|
||||
// safe without a lock for the same reason Context::result is (see AlertDialog.cpp).
|
||||
std::string lastText;
|
||||
|
||||
void onButtonDeleted(lv_event_t* e) {
|
||||
delete static_cast<ButtonContext*>(lv_event_get_user_data(e));
|
||||
}
|
||||
|
||||
std::string getResult(const Bundle& bundle) {
|
||||
std::string result;
|
||||
bundle.optString(RESULT_BUNDLE_KEY_RESULT, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
static std::string getTitleParameter(const std::shared_ptr<const Bundle>& bundle) {
|
||||
std::string result;
|
||||
if (bundle->optString(PARAMETER_BUNDLE_KEY_TITLE, result)) {
|
||||
return result;
|
||||
void onButtonPressed(lv_event_t* e) {
|
||||
auto* btnCtx = static_cast<ButtonContext*>(lv_event_get_user_data(e));
|
||||
if (btnCtx->textarea != nullptr) {
|
||||
LOG_I(TAG, "OK pressed");
|
||||
lastText = lv_textarea_get_text(btnCtx->textarea);
|
||||
btnCtx->ctx->result = 0;
|
||||
} else {
|
||||
return DEFAULT_TITLE;
|
||||
LOG_I(TAG, "Cancel pressed");
|
||||
btnCtx->ctx->result = 1;
|
||||
}
|
||||
// Async, non-blocking - see AlertDialog.cpp's onButtonPressed() for why this must not
|
||||
// call app_manager_stop() directly (would deadlock against the LVGL lock).
|
||||
AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(btnCtx->ctx->appInstanceId, &event);
|
||||
}
|
||||
|
||||
class InputDialogApp final : public App {
|
||||
void createButton(Context* ctx, lv_obj_t* parent, const std::string& text, lv_obj_t* textarea) {
|
||||
lv_obj_t* button = lv_button_create(parent);
|
||||
lv_obj_t* button_label = lv_label_create(button);
|
||||
lv_obj_align(button_label, LV_ALIGN_CENTER, 0, 0);
|
||||
lv_label_set_text(button_label, text.c_str());
|
||||
auto* btnCtx = new ButtonContext { ctx, textarea };
|
||||
lv_obj_add_event_cb(button, onButtonPressed, LV_EVENT_SHORT_CLICKED, btnCtx);
|
||||
lv_obj_add_event_cb(button, onButtonDeleted, LV_EVENT_DELETE, btnCtx);
|
||||
}
|
||||
|
||||
static void createButton(lv_obj_t* parent, const std::string& text, void* callbackContext) {
|
||||
lv_obj_t* button = lv_button_create(parent);
|
||||
lv_obj_t* button_label = lv_label_create(button);
|
||||
lv_obj_align(button_label, LV_ALIGN_CENTER, 0, 0);
|
||||
lv_label_set_text(button_label, text.c_str());
|
||||
lv_obj_add_event_cb(button, onButtonClickedCallback, LV_EVENT_SHORT_CLICKED, callbackContext);
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
// argv layout: [0]=title, [1]=message, [2]=prefilled.
|
||||
char** argv = ctx->argv;
|
||||
|
||||
auto* toolbar = lvgl_toolbar_create(parent, argv[0]);
|
||||
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||
|
||||
auto* message_label = lv_label_create(parent);
|
||||
lv_obj_align(message_label, LV_ALIGN_CENTER, 0, -20);
|
||||
lv_obj_set_width(message_label, LV_PCT(80));
|
||||
lv_label_set_text(message_label, argv[1]);
|
||||
lv_label_set_long_mode(message_label, LV_LABEL_LONG_WRAP);
|
||||
|
||||
auto* textarea = lv_textarea_create(parent);
|
||||
lv_obj_align_to(textarea, message_label, LV_ALIGN_OUT_BOTTOM_MID, 0, 4);
|
||||
lv_textarea_set_one_line(textarea, true);
|
||||
if (argv[2][0] != '\0') {
|
||||
lv_textarea_set_text(textarea, argv[2]);
|
||||
}
|
||||
|
||||
static void onButtonClickedCallback(lv_event_t* e) {
|
||||
auto app = std::static_pointer_cast<InputDialogApp>(getCurrentApp());
|
||||
assert(app != nullptr);
|
||||
app->onButtonClicked(e);
|
||||
}
|
||||
auto* button_wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_flex_flow(button_wrapper, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_size(button_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(button_wrapper, 0, 0);
|
||||
lv_obj_set_flex_align(button_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_border_width(button_wrapper, 0, 0);
|
||||
lv_obj_align(button_wrapper, LV_ALIGN_BOTTOM_MID, 0, -4);
|
||||
|
||||
void onButtonClicked(lv_event_t* e) {
|
||||
auto user_data = lv_event_get_user_data(e);
|
||||
int index = (user_data != 0) ? 0 : 1;
|
||||
LOG_I(TAG, "Selected item at index %d", index);
|
||||
if (index == 0) {
|
||||
auto bundle = std::make_unique<Bundle>();
|
||||
const char* text = lv_textarea_get_text((lv_obj_t*)user_data);
|
||||
bundle->putString(RESULT_BUNDLE_KEY_RESULT, text);
|
||||
setResult(Result::Ok, std::move(bundle));
|
||||
} else {
|
||||
setResult(Result::Cancelled);
|
||||
createButton(ctx, button_wrapper, "OK", textarea);
|
||||
createButton(ctx, button_wrapper, "Cancel", nullptr);
|
||||
}
|
||||
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
Context ctx { appInstanceId };
|
||||
ctx.argc = argc;
|
||||
ctx.argv = argv;
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
|
||||
while (true) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
if (event.type == APP_EVENT_CLOSE) {
|
||||
app_manager_finish(appInstanceId); // no-op: modal children never supersede anything
|
||||
break;
|
||||
}
|
||||
stop(manifest.appId);
|
||||
}
|
||||
|
||||
public:
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override {
|
||||
auto parameters = app.getParameters();
|
||||
check(parameters != nullptr, "Parameters missing");
|
||||
return ctx.result;
|
||||
}
|
||||
|
||||
std::string title = getTitleParameter(app.getParameters());
|
||||
auto* toolbar = lvgl_toolbar_create(parent, title.c_str());
|
||||
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||
} // namespace
|
||||
|
||||
auto* message_label = lv_label_create(parent);
|
||||
lv_obj_align(message_label, LV_ALIGN_CENTER, 0, -20);
|
||||
lv_obj_set_width(message_label, LV_PCT(80));
|
||||
uint32_t start(uint32_t callerAppInstanceId, const std::string& title, const std::string& message, const std::string& prefilled) {
|
||||
const char* argv[] = { title.c_str(), message.c_str(), prefilled.c_str() };
|
||||
uint32_t instanceId = 0;
|
||||
app_manager_start_for_result(manifest.id, callerAppInstanceId, 3, argv, &instanceId);
|
||||
return instanceId;
|
||||
}
|
||||
|
||||
std::string message;
|
||||
if (parameters->optString(PARAMETER_BUNDLE_KEY_MESSAGE, message)) {
|
||||
lv_label_set_text(message_label, message.c_str());
|
||||
lv_label_set_long_mode(message_label, LV_LABEL_LONG_WRAP);
|
||||
}
|
||||
std::string getLastText() {
|
||||
return lastText;
|
||||
}
|
||||
|
||||
auto* textarea = lv_textarea_create(parent);
|
||||
lv_obj_align_to(textarea, message_label, LV_ALIGN_OUT_BOTTOM_MID, 0, 4);
|
||||
lv_textarea_set_one_line(textarea, true);
|
||||
std::string prefilled;
|
||||
if (parameters->optString(PARAMETER_BUNDLE_KEY_PREFILLED, prefilled)) {
|
||||
lv_textarea_set_text(textarea, prefilled.c_str());
|
||||
}
|
||||
|
||||
auto* button_wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_flex_flow(button_wrapper, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_size(button_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(button_wrapper, 0, 0);
|
||||
lv_obj_set_flex_align(button_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_border_width(button_wrapper, 0, 0);
|
||||
lv_obj_align(button_wrapper, LV_ALIGN_BOTTOM_MID, 0, -4);
|
||||
|
||||
createButton(button_wrapper, "OK", textarea);
|
||||
createButton(button_wrapper, "Cancel", nullptr);
|
||||
}
|
||||
};
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "InputDialog",
|
||||
.appName = "Input Dialog",
|
||||
.appCategory = Category::System,
|
||||
.appFlags = AppManifest::Flags::Hidden,
|
||||
.createApp = create<InputDialogApp>
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "InputDialog",
|
||||
.name = "Input Dialog",
|
||||
.category = APP_CATEGORY_SYSTEM,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
|
||||
.flags = APP_MANIFEST_FLAG_HIDDEN,
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#include <lvgl/icons/shared.h>
|
||||
#include <lvgl/lvgl.h>
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/backlight.h>
|
||||
@@ -10,10 +9,16 @@
|
||||
#ifdef ESP_PLATFORM
|
||||
#include <Tactility/service/displayidle/DisplayIdleService.h>
|
||||
#endif
|
||||
#include <Tactility/app/App.h>
|
||||
#include <Tactility/lvgl/Toolbar.h>
|
||||
#include <Tactility/settings/DisplaySettings.h>
|
||||
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
|
||||
#include <lvgl.h>
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
@@ -22,9 +27,23 @@
|
||||
|
||||
namespace tt::app::kerneldisplay {
|
||||
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
constexpr auto* TAG = "KernelDisplay";
|
||||
|
||||
static Device* getBacklightDevice() {
|
||||
namespace {
|
||||
|
||||
struct Context {
|
||||
uint32_t appInstanceId;
|
||||
settings::display::DisplaySettings displaySettings;
|
||||
bool displaySettingsUpdated = false;
|
||||
lv_obj_t* timeoutSwitch = nullptr;
|
||||
lv_obj_t* timeoutDropdown = nullptr;
|
||||
lv_obj_t* screensaverDropdown = nullptr;
|
||||
};
|
||||
|
||||
|
||||
Device* getBacklightDevice() {
|
||||
Device* display;
|
||||
check(device_get_first_by_type(&DISPLAY_TYPE, &display) == ERROR_NONE);
|
||||
// Boards not yet migrated to the kernel display driver register a placeholder device (so the
|
||||
@@ -39,253 +58,292 @@ static Device* getBacklightDevice() {
|
||||
return backlight;
|
||||
}
|
||||
|
||||
class KernelDisplayApp final : public App {
|
||||
void onBackPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
|
||||
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
|
||||
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
|
||||
// deadlock against itself.
|
||||
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(ctx->appInstanceId, &closeEvent);
|
||||
}
|
||||
|
||||
settings::display::DisplaySettings displaySettings;
|
||||
bool displaySettingsUpdated = false;
|
||||
lv_obj_t* timeoutSwitch = nullptr;
|
||||
lv_obj_t* timeoutDropdown = nullptr;
|
||||
lv_obj_t* screensaverDropdown = nullptr;
|
||||
void onBacklightSliderEvent(lv_event_t* event) {
|
||||
auto* slider = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
auto* backlight = getBacklightDevice();
|
||||
assert(backlight != nullptr);
|
||||
|
||||
static void onBacklightSliderEvent(lv_event_t* event) {
|
||||
auto* slider = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
auto* app = static_cast<KernelDisplayApp*>(lv_event_get_user_data(event));
|
||||
auto* backlight = getBacklightDevice();
|
||||
assert(backlight != nullptr);
|
||||
int32_t slider_value = lv_slider_get_value(slider);
|
||||
ctx->displaySettings.backlightDuty = static_cast<uint8_t>(slider_value);
|
||||
ctx->displaySettingsUpdated = true;
|
||||
backlight_set_brightness(backlight, ctx->displaySettings.backlightDuty);
|
||||
}
|
||||
|
||||
int32_t slider_value = lv_slider_get_value(slider);
|
||||
app->displaySettings.backlightDuty = static_cast<uint8_t>(slider_value);
|
||||
app->displaySettingsUpdated = true;
|
||||
backlight_set_brightness(backlight, app->displaySettings.backlightDuty);
|
||||
void onOrientationSet(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
uint32_t selected_index = lv_dropdown_get_selected(dropdown);
|
||||
LOG_I(TAG, "Selected %u", (unsigned)selected_index);
|
||||
auto selected_orientation = static_cast<settings::display::Orientation>(selected_index);
|
||||
if (selected_orientation != ctx->displaySettings.orientation) {
|
||||
ctx->displaySettings.orientation = selected_orientation;
|
||||
ctx->displaySettingsUpdated = true;
|
||||
lv_display_set_rotation(lv_display_get_default(), settings::display::toLvglDisplayRotation(selected_orientation));
|
||||
}
|
||||
}
|
||||
|
||||
static void onOrientationSet(lv_event_t* event) {
|
||||
auto* app = static_cast<KernelDisplayApp*>(lv_event_get_user_data(event));
|
||||
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
uint32_t selected_index = lv_dropdown_get_selected(dropdown);
|
||||
LOG_I(TAG, "Selected %u", (unsigned)selected_index);
|
||||
auto selected_orientation = static_cast<settings::display::Orientation>(selected_index);
|
||||
if (selected_orientation != app->displaySettings.orientation) {
|
||||
app->displaySettings.orientation = selected_orientation;
|
||||
app->displaySettingsUpdated = true;
|
||||
lv_display_set_rotation(lv_display_get_default(), settings::display::toLvglDisplayRotation(selected_orientation));
|
||||
}
|
||||
}
|
||||
|
||||
static void onTimeoutSwitch(lv_event_t* event) {
|
||||
auto* app = static_cast<KernelDisplayApp*>(lv_event_get_user_data(event));
|
||||
auto* sw = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
bool enabled = lv_obj_has_state(sw, LV_STATE_CHECKED);
|
||||
app->displaySettings.backlightTimeoutEnabled = enabled;
|
||||
app->displaySettingsUpdated = true;
|
||||
if (app->timeoutDropdown) {
|
||||
if (enabled) {
|
||||
lv_obj_clear_state(app->timeoutDropdown, LV_STATE_DISABLED);
|
||||
if (app->screensaverDropdown) {
|
||||
lv_obj_clear_state(app->screensaverDropdown, LV_STATE_DISABLED);
|
||||
}
|
||||
} else {
|
||||
lv_obj_add_state(app->timeoutDropdown, LV_STATE_DISABLED);
|
||||
if (app->screensaverDropdown) {
|
||||
lv_obj_add_state(app->screensaverDropdown, LV_STATE_DISABLED);
|
||||
}
|
||||
void onTimeoutSwitch(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
auto* sw = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
bool enabled = lv_obj_has_state(sw, LV_STATE_CHECKED);
|
||||
ctx->displaySettings.backlightTimeoutEnabled = enabled;
|
||||
ctx->displaySettingsUpdated = true;
|
||||
if (ctx->timeoutDropdown) {
|
||||
if (enabled) {
|
||||
lv_obj_clear_state(ctx->timeoutDropdown, LV_STATE_DISABLED);
|
||||
if (ctx->screensaverDropdown) {
|
||||
lv_obj_clear_state(ctx->screensaverDropdown, LV_STATE_DISABLED);
|
||||
}
|
||||
} else {
|
||||
lv_obj_add_state(ctx->timeoutDropdown, LV_STATE_DISABLED);
|
||||
if (ctx->screensaverDropdown) {
|
||||
lv_obj_add_state(ctx->screensaverDropdown, LV_STATE_DISABLED);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void onTimeoutChanged(lv_event_t* event) {
|
||||
auto* app = static_cast<KernelDisplayApp*>(lv_event_get_user_data(event));
|
||||
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
uint32_t idx = lv_dropdown_get_selected(dropdown);
|
||||
// Map dropdown index to ms: 0=15s,1=30s,2=1m,3=2m,4=5m,5=Never
|
||||
static const uint32_t values_ms[] = {15000, 30000, 60000, 120000, 300000, 0};
|
||||
if (idx < (sizeof(values_ms)/sizeof(values_ms[0]))) {
|
||||
app->displaySettings.backlightTimeoutMs = values_ms[idx];
|
||||
app->displaySettingsUpdated = true;
|
||||
void onTimeoutChanged(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
uint32_t idx = lv_dropdown_get_selected(dropdown);
|
||||
// Map dropdown index to ms: 0=15s,1=30s,2=1m,3=2m,4=5m,5=Never
|
||||
static const uint32_t values_ms[] = {15000, 30000, 60000, 120000, 300000, 0};
|
||||
if (idx < (sizeof(values_ms)/sizeof(values_ms[0]))) {
|
||||
ctx->displaySettings.backlightTimeoutMs = values_ms[idx];
|
||||
ctx->displaySettingsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
void onScreensaverChanged(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
uint32_t idx = lv_dropdown_get_selected(dropdown);
|
||||
// Validate index bounds before casting to enum
|
||||
if (idx >= static_cast<uint32_t>(settings::display::ScreensaverType::Count)) {
|
||||
return;
|
||||
}
|
||||
auto selected_type = static_cast<settings::display::ScreensaverType>(idx);
|
||||
if (selected_type != ctx->displaySettings.screensaverType) {
|
||||
ctx->displaySettings.screensaverType = selected_type;
|
||||
ctx->displaySettingsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
|
||||
ctx->displaySettings = settings::display::loadOrGetDefault();
|
||||
auto ui_density = lvgl_get_ui_density();
|
||||
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* backlight = getBacklightDevice();
|
||||
|
||||
auto* toolbar = lvgl_toolbar_create(parent, "Display");
|
||||
// The global toolbar nav callback only knows how to stop old-model apps.
|
||||
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
|
||||
|
||||
auto* main_wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_width(main_wrapper, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(main_wrapper, 1);
|
||||
|
||||
// Backlight slider
|
||||
// Note: no gamma slider here - unlike HalDisplayApp (app/display/Display.cpp), the kernel
|
||||
// DisplayApi has no gamma curve control yet.
|
||||
|
||||
if (backlight != nullptr) {
|
||||
bool is_on_off_brightness = backlight_get_min_brightness(backlight) == 0 && backlight_get_max_brightness(backlight) == 1;
|
||||
if (!is_on_off_brightness) {
|
||||
auto* brightness_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(brightness_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_hor(brightness_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(brightness_wrapper, 0, LV_STATE_DEFAULT);
|
||||
if (ui_density != LVGL_UI_DENSITY_COMPACT) {
|
||||
lv_obj_set_style_pad_ver(brightness_wrapper, 4, LV_STATE_DEFAULT);
|
||||
}
|
||||
|
||||
auto* brightness_label = lv_label_create(brightness_wrapper);
|
||||
lv_label_set_text(brightness_label, "Brightness");
|
||||
lv_obj_align(brightness_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
|
||||
auto* brightness_slider = lv_slider_create(brightness_wrapper);
|
||||
lv_obj_set_width(brightness_slider, LV_PCT(50));
|
||||
lv_obj_align(brightness_slider, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_slider_set_range(brightness_slider, backlight_get_min_brightness(backlight), backlight_get_max_brightness(backlight));
|
||||
lv_obj_add_event_cb(brightness_slider, onBacklightSliderEvent, LV_EVENT_VALUE_CHANGED, ctx);
|
||||
|
||||
lv_slider_set_value(brightness_slider, ctx->displaySettings.backlightDuty, LV_ANIM_OFF);
|
||||
}
|
||||
}
|
||||
|
||||
static void onScreensaverChanged(lv_event_t* event) {
|
||||
auto* app = static_cast<KernelDisplayApp*>(lv_event_get_user_data(event));
|
||||
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
uint32_t idx = lv_dropdown_get_selected(dropdown);
|
||||
// Validate index bounds before casting to enum
|
||||
if (idx >= static_cast<uint32_t>(settings::display::ScreensaverType::Count)) {
|
||||
return;
|
||||
// Orientation
|
||||
|
||||
auto* orientation_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(orientation_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(orientation_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(orientation_wrapper, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* orientation_label = lv_label_create(orientation_wrapper);
|
||||
lv_label_set_text(orientation_label, "Orientation");
|
||||
lv_obj_align(orientation_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
|
||||
auto* orientation_dropdown = lv_dropdown_create(orientation_wrapper);
|
||||
// Note: order correlates with settings::display::Orientation item order
|
||||
lv_dropdown_set_options(orientation_dropdown, "Landscape\nPortrait Right\nLandscape Flipped\nPortrait Left");
|
||||
lv_obj_align(orientation_dropdown, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_obj_add_event_cb(orientation_dropdown, onOrientationSet, LV_EVENT_VALUE_CHANGED, ctx);
|
||||
// Set the dropdown to match current orientation enum
|
||||
lv_dropdown_set_selected(orientation_dropdown, static_cast<uint16_t>(ctx->displaySettings.orientation));
|
||||
|
||||
// Screen timeout
|
||||
// Note: DisplayIdleService doesn't act on these settings for kernel-driver displays yet
|
||||
// (it only looks up the deprecated tt::hal::display::DisplayDevice), so these currently
|
||||
// just get saved without taking effect. Kept for parity/forward-compatibility.
|
||||
|
||||
if (backlight != nullptr) {
|
||||
auto* timeout_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(timeout_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(timeout_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(timeout_wrapper, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* timeout_label = lv_label_create(timeout_wrapper);
|
||||
lv_label_set_text(timeout_label, "Auto screen off");
|
||||
lv_obj_align(timeout_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
|
||||
ctx->timeoutSwitch = lv_switch_create(timeout_wrapper);
|
||||
if (ctx->displaySettings.backlightTimeoutEnabled) {
|
||||
lv_obj_add_state(ctx->timeoutSwitch, LV_STATE_CHECKED);
|
||||
}
|
||||
auto selected_type = static_cast<settings::display::ScreensaverType>(idx);
|
||||
if (selected_type != app->displaySettings.screensaverType) {
|
||||
app->displaySettings.screensaverType = selected_type;
|
||||
app->displaySettingsUpdated = true;
|
||||
lv_obj_align(ctx->timeoutSwitch, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_obj_add_event_cb(ctx->timeoutSwitch, onTimeoutSwitch, LV_EVENT_VALUE_CHANGED, ctx);
|
||||
|
||||
auto* timeout_select_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(timeout_select_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(timeout_select_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(timeout_select_wrapper, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* timeout_value_label = lv_label_create(timeout_select_wrapper);
|
||||
lv_label_set_text(timeout_value_label, "Timeout");
|
||||
lv_obj_align(timeout_value_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
|
||||
ctx->timeoutDropdown = lv_dropdown_create(timeout_select_wrapper);
|
||||
lv_dropdown_set_options(ctx->timeoutDropdown, "15 seconds\n30 seconds\n1 minute\n2 minutes\n5 minutes\nNever");
|
||||
lv_obj_align(ctx->timeoutDropdown, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_obj_add_event_cb(ctx->timeoutDropdown, onTimeoutChanged, LV_EVENT_VALUE_CHANGED, ctx);
|
||||
// Initialize dropdown selection from settings
|
||||
uint32_t ms = ctx->displaySettings.backlightTimeoutMs;
|
||||
uint32_t idx = 2; // default 1 minute
|
||||
if (ms == 15000) idx = 0;
|
||||
else if (ms == 30000)
|
||||
idx = 1;
|
||||
else if (ms == 60000)
|
||||
idx = 2;
|
||||
else if (ms == 120000)
|
||||
idx = 3;
|
||||
else if (ms == 300000)
|
||||
idx = 4;
|
||||
else if (ms == 0)
|
||||
idx = 5;
|
||||
lv_dropdown_set_selected(ctx->timeoutDropdown, idx);
|
||||
if (!ctx->displaySettings.backlightTimeoutEnabled) {
|
||||
lv_obj_add_state(ctx->timeoutDropdown, LV_STATE_DISABLED);
|
||||
}
|
||||
|
||||
// Screensaver type
|
||||
auto* screensaver_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(screensaver_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(screensaver_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(screensaver_wrapper, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* screensaver_label = lv_label_create(screensaver_wrapper);
|
||||
lv_label_set_text(screensaver_label, "Screensaver");
|
||||
lv_obj_align(screensaver_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
|
||||
ctx->screensaverDropdown = lv_dropdown_create(screensaver_wrapper);
|
||||
// Note: order correlates with settings::display::ScreensaverType enum order
|
||||
lv_dropdown_set_options(ctx->screensaverDropdown, "None\nBouncing Balls\nMystify\nMatrix Rain\nStackChan");
|
||||
lv_obj_align(ctx->screensaverDropdown, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_obj_add_event_cb(ctx->screensaverDropdown, onScreensaverChanged, LV_EVENT_VALUE_CHANGED, ctx);
|
||||
lv_dropdown_set_selected(ctx->screensaverDropdown, static_cast<uint16_t>(ctx->displaySettings.screensaverType));
|
||||
if (!ctx->displaySettings.backlightTimeoutEnabled) {
|
||||
lv_obj_add_state(ctx->screensaverDropdown, LV_STATE_DISABLED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override {
|
||||
displaySettings = settings::display::loadOrGetDefault();
|
||||
auto ui_density = lvgl_get_ui_density();
|
||||
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* backlight = getBacklightDevice();
|
||||
|
||||
lvgl::toolbar_create(parent, app);
|
||||
|
||||
auto* main_wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_width(main_wrapper, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(main_wrapper, 1);
|
||||
|
||||
// Backlight slider
|
||||
// Note: no gamma slider here - unlike HalDisplayApp (app/display/Display.cpp), the kernel
|
||||
// DisplayApi has no gamma curve control yet.
|
||||
|
||||
if (backlight != nullptr) {
|
||||
bool is_on_off_brightness = backlight_get_min_brightness(backlight) == 0 && backlight_get_max_brightness(backlight) == 1;
|
||||
if (!is_on_off_brightness) {
|
||||
auto* brightness_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(brightness_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_hor(brightness_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(brightness_wrapper, 0, LV_STATE_DEFAULT);
|
||||
if (ui_density != LVGL_UI_DENSITY_COMPACT) {
|
||||
lv_obj_set_style_pad_ver(brightness_wrapper, 4, LV_STATE_DEFAULT);
|
||||
}
|
||||
|
||||
auto* brightness_label = lv_label_create(brightness_wrapper);
|
||||
lv_label_set_text(brightness_label, "Brightness");
|
||||
lv_obj_align(brightness_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
|
||||
auto* brightness_slider = lv_slider_create(brightness_wrapper);
|
||||
lv_obj_set_width(brightness_slider, LV_PCT(50));
|
||||
lv_obj_align(brightness_slider, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_slider_set_range(brightness_slider, backlight_get_min_brightness(backlight), backlight_get_max_brightness(backlight));
|
||||
lv_obj_add_event_cb(brightness_slider, onBacklightSliderEvent, LV_EVENT_VALUE_CHANGED, this);
|
||||
|
||||
lv_slider_set_value(brightness_slider, displaySettings.backlightDuty, LV_ANIM_OFF);
|
||||
}
|
||||
}
|
||||
|
||||
// Orientation
|
||||
|
||||
auto* orientation_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(orientation_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(orientation_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(orientation_wrapper, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* orientation_label = lv_label_create(orientation_wrapper);
|
||||
lv_label_set_text(orientation_label, "Orientation");
|
||||
lv_obj_align(orientation_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
|
||||
auto* orientation_dropdown = lv_dropdown_create(orientation_wrapper);
|
||||
// Note: order correlates with settings::display::Orientation item order
|
||||
lv_dropdown_set_options(orientation_dropdown, "Landscape\nPortrait Right\nLandscape Flipped\nPortrait Left");
|
||||
lv_obj_align(orientation_dropdown, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_obj_add_event_cb(orientation_dropdown, onOrientationSet, LV_EVENT_VALUE_CHANGED, this);
|
||||
// Set the dropdown to match current orientation enum
|
||||
lv_dropdown_set_selected(orientation_dropdown, static_cast<uint16_t>(displaySettings.orientation));
|
||||
|
||||
// Screen timeout
|
||||
// Note: DisplayIdleService doesn't act on these settings for kernel-driver displays yet
|
||||
// (it only looks up the deprecated tt::hal::display::DisplayDevice), so these currently
|
||||
// just get saved without taking effect. Kept for parity/forward-compatibility.
|
||||
|
||||
if (backlight != nullptr) {
|
||||
auto* timeout_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(timeout_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(timeout_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(timeout_wrapper, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* timeout_label = lv_label_create(timeout_wrapper);
|
||||
lv_label_set_text(timeout_label, "Auto screen off");
|
||||
lv_obj_align(timeout_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
|
||||
timeoutSwitch = lv_switch_create(timeout_wrapper);
|
||||
if (displaySettings.backlightTimeoutEnabled) {
|
||||
lv_obj_add_state(timeoutSwitch, LV_STATE_CHECKED);
|
||||
}
|
||||
lv_obj_align(timeoutSwitch, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_obj_add_event_cb(timeoutSwitch, onTimeoutSwitch, LV_EVENT_VALUE_CHANGED, this);
|
||||
|
||||
auto* timeout_select_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(timeout_select_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(timeout_select_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(timeout_select_wrapper, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* timeout_value_label = lv_label_create(timeout_select_wrapper);
|
||||
lv_label_set_text(timeout_value_label, "Timeout");
|
||||
lv_obj_align(timeout_value_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
|
||||
timeoutDropdown = lv_dropdown_create(timeout_select_wrapper);
|
||||
lv_dropdown_set_options(timeoutDropdown, "15 seconds\n30 seconds\n1 minute\n2 minutes\n5 minutes\nNever");
|
||||
lv_obj_align(timeoutDropdown, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_obj_add_event_cb(timeoutDropdown, onTimeoutChanged, LV_EVENT_VALUE_CHANGED, this);
|
||||
// Initialize dropdown selection from settings
|
||||
uint32_t ms = displaySettings.backlightTimeoutMs;
|
||||
uint32_t idx = 2; // default 1 minute
|
||||
if (ms == 15000) idx = 0;
|
||||
else if (ms == 30000)
|
||||
idx = 1;
|
||||
else if (ms == 60000)
|
||||
idx = 2;
|
||||
else if (ms == 120000)
|
||||
idx = 3;
|
||||
else if (ms == 300000)
|
||||
idx = 4;
|
||||
else if (ms == 0)
|
||||
idx = 5;
|
||||
lv_dropdown_set_selected(timeoutDropdown, idx);
|
||||
if (!displaySettings.backlightTimeoutEnabled) {
|
||||
lv_obj_add_state(timeoutDropdown, LV_STATE_DISABLED);
|
||||
}
|
||||
|
||||
// Screensaver type
|
||||
auto* screensaver_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(screensaver_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(screensaver_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(screensaver_wrapper, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* screensaver_label = lv_label_create(screensaver_wrapper);
|
||||
lv_label_set_text(screensaver_label, "Screensaver");
|
||||
lv_obj_align(screensaver_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
|
||||
screensaverDropdown = lv_dropdown_create(screensaver_wrapper);
|
||||
// Note: order correlates with settings::display::ScreensaverType enum order
|
||||
lv_dropdown_set_options(screensaverDropdown, "None\nBouncing Balls\nMystify\nMatrix Rain\nStackChan");
|
||||
lv_obj_align(screensaverDropdown, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_obj_add_event_cb(screensaverDropdown, onScreensaverChanged, LV_EVENT_VALUE_CHANGED, this);
|
||||
lv_dropdown_set_selected(screensaverDropdown, static_cast<uint16_t>(displaySettings.screensaverType));
|
||||
if (!displaySettings.backlightTimeoutEnabled) {
|
||||
lv_obj_add_state(screensaverDropdown, LV_STATE_DISABLED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void onHide(AppContext& app) override {
|
||||
if (displaySettingsUpdated) {
|
||||
// Dispatch it, so file IO doesn't block the UI
|
||||
const settings::display::DisplaySettings settings_to_save = displaySettings;
|
||||
getMainDispatcher().dispatch([settings_to_save] {
|
||||
settings::display::save(settings_to_save);
|
||||
// Mirrors the old onHide() behaviour: persist the settings (regardless of whether the app is
|
||||
// giving up its thread for a save/resume cycle, or closing for good) whenever they changed.
|
||||
void persistIfUpdated(Context& ctx) {
|
||||
if (ctx.displaySettingsUpdated) {
|
||||
// Dispatch it, so file IO doesn't block the UI
|
||||
const settings::display::DisplaySettings settings_to_save = ctx.displaySettings;
|
||||
getMainDispatcher().dispatch([settings_to_save] {
|
||||
settings::display::save(settings_to_save);
|
||||
#ifdef ESP_PLATFORM
|
||||
// Notify DisplayIdle service to reload settings
|
||||
auto displayIdle = service::displayidle::findService();
|
||||
if (displayIdle) {
|
||||
displayIdle->reloadSettings();
|
||||
}
|
||||
// Notify DisplayIdle service to reload settings
|
||||
auto displayIdle = service::displayidle::findService();
|
||||
if (displayIdle) {
|
||||
displayIdle->reloadSettings();
|
||||
}
|
||||
#endif
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
Context ctx {};
|
||||
ctx.appInstanceId = appInstanceId;
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
|
||||
bool shouldClose = false;
|
||||
while (!shouldClose) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
switch (event.type) {
|
||||
case APP_EVENT_CLOSE:
|
||||
persistIfUpdated(ctx);
|
||||
app_manager_finish(appInstanceId);
|
||||
shouldClose = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "Display",
|
||||
.appName = "Display",
|
||||
.appIcon = LVGL_ICON_SHARED_DISPLAY_SETTINGS,
|
||||
.appCategory = Category::Settings,
|
||||
.createApp = create<KernelDisplayApp>
|
||||
};
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "Display",
|
||||
.name = "Display",
|
||||
.category = APP_CATEGORY_SETTINGS,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
|
||||
};
|
||||
|
||||
} // namespace tt::app::kerneldisplay
|
||||
|
||||
@@ -3,16 +3,23 @@
|
||||
#include <Tactility/Tactility.h>
|
||||
|
||||
#include <Tactility/settings/KeyboardSettings.h>
|
||||
#include <Tactility/lvgl/Toolbar.h>
|
||||
|
||||
#include <lvgl/icons/shared.h>
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/backlight.h>
|
||||
|
||||
#include <lvgl.h>
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
|
||||
namespace tt::app::keyboardsettings {
|
||||
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
constexpr auto* TAG = "KeyboardSettings";
|
||||
|
||||
// Shared timeout values: 15s, 30s, 1m, 2m, 5m, Never (0)
|
||||
@@ -35,157 +42,209 @@ static void applyKeyboardBacklight(bool enabled, uint8_t brightness) {
|
||||
}
|
||||
}
|
||||
|
||||
class KeyboardSettingsApp final : public App {
|
||||
namespace {
|
||||
|
||||
struct Context {
|
||||
uint32_t appInstanceId;
|
||||
settings::keyboard::KeyboardSettings kbSettings;
|
||||
bool updated = false;
|
||||
lv_obj_t* switchBacklight = nullptr;
|
||||
lv_obj_t* sliderBrightness = nullptr;
|
||||
lv_obj_t* switchTimeoutEnable = nullptr;
|
||||
lv_obj_t* timeoutDropdown = nullptr;
|
||||
|
||||
static void onBacklightSwitch(lv_event_t* e) {
|
||||
auto* app = static_cast<KeyboardSettingsApp*>(lv_event_get_user_data(e));
|
||||
bool enabled = lv_obj_has_state(app->switchBacklight, LV_STATE_CHECKED);
|
||||
app->kbSettings.backlightEnabled = enabled;
|
||||
app->updated = true;
|
||||
if (app->sliderBrightness) {
|
||||
if (enabled) lv_obj_clear_state(app->sliderBrightness, LV_STATE_DISABLED);
|
||||
else lv_obj_add_state(app->sliderBrightness, LV_STATE_DISABLED);
|
||||
}
|
||||
applyKeyboardBacklight(enabled, app->kbSettings.backlightBrightness);
|
||||
}
|
||||
|
||||
static void onBrightnessChanged(lv_event_t* e) {
|
||||
auto* app = static_cast<KeyboardSettingsApp*>(lv_event_get_user_data(e));
|
||||
int32_t v = lv_slider_get_value(app->sliderBrightness);
|
||||
app->kbSettings.backlightBrightness = static_cast<uint8_t>(v);
|
||||
app->updated = true;
|
||||
if (app->kbSettings.backlightEnabled) {
|
||||
applyKeyboardBacklight(true, app->kbSettings.backlightBrightness);
|
||||
}
|
||||
}
|
||||
|
||||
static void onTimeoutEnableSwitch(lv_event_t* e) {
|
||||
auto* app = static_cast<KeyboardSettingsApp*>(lv_event_get_user_data(e));
|
||||
bool enabled = lv_obj_has_state(app->switchTimeoutEnable, LV_STATE_CHECKED);
|
||||
app->kbSettings.backlightTimeoutEnabled = enabled;
|
||||
app->updated = true;
|
||||
if (app->timeoutDropdown) {
|
||||
if (enabled) {
|
||||
lv_obj_clear_state(app->timeoutDropdown, LV_STATE_DISABLED);
|
||||
} else {
|
||||
lv_obj_add_state(app->timeoutDropdown, LV_STATE_DISABLED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void onTimeoutChanged(lv_event_t* event) {
|
||||
auto* app = static_cast<KeyboardSettingsApp*>(lv_event_get_user_data(event));
|
||||
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
uint32_t idx = lv_dropdown_get_selected(dropdown);
|
||||
if (idx < (sizeof(TIMEOUT_VALUES_MS) / sizeof(TIMEOUT_VALUES_MS[0]))) {
|
||||
app->kbSettings.backlightTimeoutMs = TIMEOUT_VALUES_MS[idx];
|
||||
app->updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override {
|
||||
kbSettings = settings::keyboard::loadOrGetDefault();
|
||||
updated = false;
|
||||
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
lvgl::toolbar_create(parent, app);
|
||||
|
||||
auto* main_wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_width(main_wrapper, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(main_wrapper, 1);
|
||||
|
||||
// Keyboard backlight toggle
|
||||
auto* bl_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(bl_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(bl_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(bl_wrapper, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* bl_label = lv_label_create(bl_wrapper);
|
||||
lv_label_set_text(bl_label, "Keyboard backlight");
|
||||
lv_obj_align(bl_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
switchBacklight = lv_switch_create(bl_wrapper);
|
||||
if (kbSettings.backlightEnabled) lv_obj_add_state(switchBacklight, LV_STATE_CHECKED);
|
||||
lv_obj_align(switchBacklight, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_obj_add_event_cb(switchBacklight, onBacklightSwitch, LV_EVENT_VALUE_CHANGED, this);
|
||||
|
||||
// Brightness slider
|
||||
auto* br_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(br_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(br_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(br_wrapper, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* br_label = lv_label_create(br_wrapper);
|
||||
lv_label_set_text(br_label, "Brightness");
|
||||
lv_obj_align(br_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
sliderBrightness = lv_slider_create(br_wrapper);
|
||||
lv_obj_set_width(sliderBrightness, LV_PCT(50));
|
||||
lv_obj_align(sliderBrightness, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_slider_set_range(sliderBrightness, 0, 255);
|
||||
lv_slider_set_value(sliderBrightness, kbSettings.backlightBrightness, LV_ANIM_OFF);
|
||||
if (!kbSettings.backlightEnabled) lv_obj_add_state(sliderBrightness, LV_STATE_DISABLED);
|
||||
lv_obj_add_event_cb(sliderBrightness, onBrightnessChanged, LV_EVENT_VALUE_CHANGED, this);
|
||||
|
||||
// Backlight timeout enable
|
||||
auto* to_enable_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(to_enable_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(to_enable_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(to_enable_wrapper, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* to_enable_label = lv_label_create(to_enable_wrapper);
|
||||
lv_label_set_text(to_enable_label, "Auto backlight off");
|
||||
lv_obj_align(to_enable_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
switchTimeoutEnable = lv_switch_create(to_enable_wrapper);
|
||||
if (kbSettings.backlightTimeoutEnabled) lv_obj_add_state(switchTimeoutEnable, LV_STATE_CHECKED);
|
||||
lv_obj_align(switchTimeoutEnable, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_obj_add_event_cb(switchTimeoutEnable, onTimeoutEnableSwitch, LV_EVENT_VALUE_CHANGED, this);
|
||||
|
||||
auto* timeout_select_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(timeout_select_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(timeout_select_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(timeout_select_wrapper, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* timeout_value_label = lv_label_create(timeout_select_wrapper);
|
||||
lv_label_set_text(timeout_value_label, "Timeout");
|
||||
lv_obj_align(timeout_value_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
|
||||
// Backlight timeout value (seconds)
|
||||
timeoutDropdown = lv_dropdown_create(timeout_select_wrapper);
|
||||
lv_dropdown_set_options(timeoutDropdown, "15 seconds\n30 seconds\n1 minute\n2 minutes\n5 minutes\nNever");
|
||||
lv_obj_align(timeoutDropdown, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_obj_add_event_cb(timeoutDropdown, onTimeoutChanged, LV_EVENT_VALUE_CHANGED, this);
|
||||
// Initialize dropdown selection from settings
|
||||
lv_dropdown_set_selected(timeoutDropdown, timeoutMsToIndex(kbSettings.backlightTimeoutMs));
|
||||
if (!kbSettings.backlightTimeoutEnabled) {
|
||||
lv_obj_add_state(timeoutDropdown, LV_STATE_DISABLED);
|
||||
}
|
||||
}
|
||||
|
||||
void onHide(AppContext& app) override {
|
||||
if (updated) {
|
||||
const auto copy = kbSettings;
|
||||
getMainDispatcher().dispatch([copy]{ settings::keyboard::save(copy); });
|
||||
updated = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "KeyboardSettings",
|
||||
.appName = "Keyboard",
|
||||
.appIcon = LVGL_ICON_SHARED_KEYBOARD_ALT,
|
||||
.appCategory = Category::Settings,
|
||||
.createApp = create<KeyboardSettingsApp>
|
||||
|
||||
void onBackPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
|
||||
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
|
||||
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
|
||||
// deadlock against itself.
|
||||
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(ctx->appInstanceId, &closeEvent);
|
||||
}
|
||||
|
||||
void onBacklightSwitch(lv_event_t* e) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
|
||||
bool enabled = lv_obj_has_state(ctx->switchBacklight, LV_STATE_CHECKED);
|
||||
ctx->kbSettings.backlightEnabled = enabled;
|
||||
ctx->updated = true;
|
||||
if (ctx->sliderBrightness) {
|
||||
if (enabled) lv_obj_clear_state(ctx->sliderBrightness, LV_STATE_DISABLED);
|
||||
else lv_obj_add_state(ctx->sliderBrightness, LV_STATE_DISABLED);
|
||||
}
|
||||
applyKeyboardBacklight(enabled, ctx->kbSettings.backlightBrightness);
|
||||
}
|
||||
|
||||
void onBrightnessChanged(lv_event_t* e) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
|
||||
int32_t v = lv_slider_get_value(ctx->sliderBrightness);
|
||||
ctx->kbSettings.backlightBrightness = static_cast<uint8_t>(v);
|
||||
ctx->updated = true;
|
||||
if (ctx->kbSettings.backlightEnabled) {
|
||||
applyKeyboardBacklight(true, ctx->kbSettings.backlightBrightness);
|
||||
}
|
||||
}
|
||||
|
||||
void onTimeoutEnableSwitch(lv_event_t* e) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
|
||||
bool enabled = lv_obj_has_state(ctx->switchTimeoutEnable, LV_STATE_CHECKED);
|
||||
ctx->kbSettings.backlightTimeoutEnabled = enabled;
|
||||
ctx->updated = true;
|
||||
if (ctx->timeoutDropdown) {
|
||||
if (enabled) {
|
||||
lv_obj_clear_state(ctx->timeoutDropdown, LV_STATE_DISABLED);
|
||||
} else {
|
||||
lv_obj_add_state(ctx->timeoutDropdown, LV_STATE_DISABLED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void onTimeoutChanged(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
uint32_t idx = lv_dropdown_get_selected(dropdown);
|
||||
if (idx < (sizeof(TIMEOUT_VALUES_MS) / sizeof(TIMEOUT_VALUES_MS[0]))) {
|
||||
ctx->kbSettings.backlightTimeoutMs = TIMEOUT_VALUES_MS[idx];
|
||||
ctx->updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
|
||||
ctx->kbSettings = settings::keyboard::loadOrGetDefault();
|
||||
ctx->updated = false;
|
||||
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* toolbar = lvgl_toolbar_create(parent, "Keyboard");
|
||||
// The global toolbar nav callback only knows how to stop old-model apps.
|
||||
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
|
||||
|
||||
auto* main_wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_width(main_wrapper, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(main_wrapper, 1);
|
||||
|
||||
// Keyboard backlight toggle
|
||||
auto* bl_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(bl_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(bl_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(bl_wrapper, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* bl_label = lv_label_create(bl_wrapper);
|
||||
lv_label_set_text(bl_label, "Keyboard backlight");
|
||||
lv_obj_align(bl_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
ctx->switchBacklight = lv_switch_create(bl_wrapper);
|
||||
if (ctx->kbSettings.backlightEnabled) lv_obj_add_state(ctx->switchBacklight, LV_STATE_CHECKED);
|
||||
lv_obj_align(ctx->switchBacklight, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_obj_add_event_cb(ctx->switchBacklight, onBacklightSwitch, LV_EVENT_VALUE_CHANGED, ctx);
|
||||
|
||||
// Brightness slider
|
||||
auto* br_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(br_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(br_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(br_wrapper, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* br_label = lv_label_create(br_wrapper);
|
||||
lv_label_set_text(br_label, "Brightness");
|
||||
lv_obj_align(br_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
ctx->sliderBrightness = lv_slider_create(br_wrapper);
|
||||
lv_obj_set_width(ctx->sliderBrightness, LV_PCT(50));
|
||||
lv_obj_align(ctx->sliderBrightness, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_slider_set_range(ctx->sliderBrightness, 0, 255);
|
||||
lv_slider_set_value(ctx->sliderBrightness, ctx->kbSettings.backlightBrightness, LV_ANIM_OFF);
|
||||
if (!ctx->kbSettings.backlightEnabled) lv_obj_add_state(ctx->sliderBrightness, LV_STATE_DISABLED);
|
||||
lv_obj_add_event_cb(ctx->sliderBrightness, onBrightnessChanged, LV_EVENT_VALUE_CHANGED, ctx);
|
||||
|
||||
// Backlight timeout enable
|
||||
auto* to_enable_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(to_enable_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(to_enable_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(to_enable_wrapper, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* to_enable_label = lv_label_create(to_enable_wrapper);
|
||||
lv_label_set_text(to_enable_label, "Auto backlight off");
|
||||
lv_obj_align(to_enable_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
ctx->switchTimeoutEnable = lv_switch_create(to_enable_wrapper);
|
||||
if (ctx->kbSettings.backlightTimeoutEnabled) lv_obj_add_state(ctx->switchTimeoutEnable, LV_STATE_CHECKED);
|
||||
lv_obj_align(ctx->switchTimeoutEnable, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_obj_add_event_cb(ctx->switchTimeoutEnable, onTimeoutEnableSwitch, LV_EVENT_VALUE_CHANGED, ctx);
|
||||
|
||||
auto* timeout_select_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_size(timeout_select_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(timeout_select_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(timeout_select_wrapper, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* timeout_value_label = lv_label_create(timeout_select_wrapper);
|
||||
lv_label_set_text(timeout_value_label, "Timeout");
|
||||
lv_obj_align(timeout_value_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
|
||||
// Backlight timeout value (seconds)
|
||||
ctx->timeoutDropdown = lv_dropdown_create(timeout_select_wrapper);
|
||||
lv_dropdown_set_options(ctx->timeoutDropdown, "15 seconds\n30 seconds\n1 minute\n2 minutes\n5 minutes\nNever");
|
||||
lv_obj_align(ctx->timeoutDropdown, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_obj_add_event_cb(ctx->timeoutDropdown, onTimeoutChanged, LV_EVENT_VALUE_CHANGED, ctx);
|
||||
// Initialize dropdown selection from settings
|
||||
lv_dropdown_set_selected(ctx->timeoutDropdown, timeoutMsToIndex(ctx->kbSettings.backlightTimeoutMs));
|
||||
if (!ctx->kbSettings.backlightTimeoutEnabled) {
|
||||
lv_obj_add_state(ctx->timeoutDropdown, LV_STATE_DISABLED);
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the old onHide() behaviour: persist the settings (regardless of whether the app is
|
||||
// giving up its thread for a save/resume cycle, or closing for good) whenever they changed.
|
||||
void persistIfUpdated(Context& ctx) {
|
||||
if (ctx.updated) {
|
||||
const auto copy = ctx.kbSettings;
|
||||
getMainDispatcher().dispatch([copy]{ settings::keyboard::save(copy); });
|
||||
ctx.updated = false;
|
||||
}
|
||||
}
|
||||
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
Context ctx {};
|
||||
ctx.appInstanceId = appInstanceId;
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
|
||||
bool shouldClose = false;
|
||||
while (!shouldClose) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
switch (event.type) {
|
||||
case APP_EVENT_CLOSE:
|
||||
persistIfUpdated(ctx);
|
||||
app_manager_finish(appInstanceId);
|
||||
shouldClose = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "KeyboardSettings",
|
||||
.name = "Keyboard",
|
||||
.category = APP_CATEGORY_SETTINGS,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
#include <Tactility/Tactility.h>
|
||||
|
||||
#include <Tactility/app/AppContext.h>
|
||||
#include <Tactility/app/AppPaths.h>
|
||||
#include <Tactility/app/AppRegistration.h>
|
||||
#include <Tactility/app/setup/Setup.h>
|
||||
#include <Tactility/service/loader/Loader.h>
|
||||
#include <Tactility/settings/BootSettings.h>
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <lvgl.h>
|
||||
|
||||
#include <lvgl.h>
|
||||
#include <lvgl/icons/launcher.h>
|
||||
#include <lvgl/fonts.h>
|
||||
#include <lvgl/lvgl.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/power_supply.h>
|
||||
#include <tactility/log.h>
|
||||
|
||||
#include <Tactility/app/setup/Setup.h>
|
||||
#include <Tactility/settings/BootSettings.h>
|
||||
#include <Tactility/Tactility.h>
|
||||
|
||||
namespace tt::app::launcher {
|
||||
|
||||
constexpr auto* TAG = "Launcher";
|
||||
|
||||
static uint32_t getButtonPadding(UiDensity density, uint32_t buttonSize) {
|
||||
namespace {
|
||||
|
||||
uint32_t getButtonPadding(UiDensity density, uint32_t buttonSize) {
|
||||
if (density == LVGL_UI_DENSITY_COMPACT) {
|
||||
return 0;
|
||||
} else {
|
||||
@@ -29,200 +33,234 @@ static uint32_t getButtonPadding(UiDensity density, uint32_t buttonSize) {
|
||||
}
|
||||
}
|
||||
|
||||
static int32_t computeButtonMargin(int32_t available_span, int32_t total_button_size) {
|
||||
int32_t computeButtonMargin(int32_t available_span, int32_t total_button_size) {
|
||||
const int32_t usable = std::max<int32_t>(0, available_span - (3 * total_button_size));
|
||||
return std::min<int32_t>(usable / 16, total_button_size / 2);
|
||||
}
|
||||
|
||||
class LauncherApp final : public App {
|
||||
void onAppPressed(lv_event_t* e) {
|
||||
auto* appId = static_cast<const char*>(lv_event_get_user_data(e));
|
||||
uint32_t instance_id = 0;
|
||||
app_manager_start(appId, &instance_id);
|
||||
}
|
||||
|
||||
static lv_obj_t* createAppButton(lv_obj_t* parent, UiDensity uiDensity, const char* imageFile, const char* appId, int32_t itemMargin, bool isLandscape) {
|
||||
const auto button_size = lvgl_get_launcher_icon_font_height();
|
||||
const auto button_padding = getButtonPadding(uiDensity, button_size);
|
||||
auto* apps_button = lv_button_create(parent);
|
||||
lv_obj_t* createAppButton(lv_obj_t* parent, UiDensity uiDensity, const char* imageFile, const char* appId, int32_t itemMargin, bool isLandscape) {
|
||||
const auto button_size = lvgl_get_launcher_icon_font_height();
|
||||
const auto button_padding = getButtonPadding(uiDensity, button_size);
|
||||
auto* apps_button = lv_button_create(parent);
|
||||
|
||||
lv_obj_set_style_pad_all(apps_button, static_cast<int32_t>(button_padding), LV_STATE_DEFAULT);
|
||||
if (isLandscape) {
|
||||
lv_obj_set_style_margin_hor(apps_button, itemMargin, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_all(apps_button, static_cast<int32_t>(button_padding), LV_STATE_DEFAULT);
|
||||
if (isLandscape) {
|
||||
lv_obj_set_style_margin_hor(apps_button, itemMargin, LV_STATE_DEFAULT);
|
||||
} else {
|
||||
lv_obj_set_style_margin_ver(apps_button, itemMargin, LV_STATE_DEFAULT);
|
||||
}
|
||||
|
||||
lv_obj_set_style_shadow_width(apps_button, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_bg_opa(apps_button, 0, LV_STATE_DEFAULT);
|
||||
|
||||
// create the image first
|
||||
auto* button_image = lv_image_create(apps_button);
|
||||
lv_obj_set_style_text_font(button_image, lvgl_get_launcher_icon_font(), LV_STATE_DEFAULT);
|
||||
lv_image_set_src(button_image, imageFile);
|
||||
lv_obj_set_style_text_color(button_image, lv_theme_get_color_primary(button_image), LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_image_recolor(button_image, lv_theme_get_color_primary(parent), LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_image_recolor_opa(button_image, LV_OPA_COVER, LV_STATE_DEFAULT);
|
||||
|
||||
// Ensure it's square (Material Symbols are slightly wider than tall)
|
||||
lv_obj_set_size(button_image, button_size, button_size);
|
||||
|
||||
lv_obj_add_event_cb(apps_button, onAppPressed, LV_EVENT_SHORT_CLICKED, (void*)appId);
|
||||
|
||||
return apps_button;
|
||||
}
|
||||
|
||||
bool shouldShowPowerButton() {
|
||||
bool show_power_button = false;
|
||||
device_for_each_of_type(&POWER_SUPPLY_TYPE, &show_power_button, [](Device* device, void* context) {
|
||||
if (device_is_ready(device) && power_supply_supports_power_off(device)) {
|
||||
*static_cast<bool*>(context) = true;
|
||||
return false; // stop iterating
|
||||
} else {
|
||||
lv_obj_set_style_margin_ver(apps_button, itemMargin, LV_STATE_DEFAULT);
|
||||
return true; // continue iterating
|
||||
}
|
||||
});
|
||||
return show_power_button;
|
||||
}
|
||||
|
||||
lv_obj_set_style_shadow_width(apps_button, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_bg_opa(apps_button, 0, LV_STATE_DEFAULT);
|
||||
void onButtonsWrapperResized(lv_event_t* e);
|
||||
|
||||
// create the image first
|
||||
auto* button_image = lv_image_create(apps_button);
|
||||
lv_obj_set_style_text_font(button_image, lvgl_get_launcher_icon_font(), LV_STATE_DEFAULT);
|
||||
lv_image_set_src(button_image, imageFile);
|
||||
lv_obj_set_style_text_color(button_image, lv_theme_get_color_primary(button_image), LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_image_recolor(button_image, lv_theme_get_color_primary(parent), LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_image_recolor_opa(button_image, LV_OPA_COVER, LV_STATE_DEFAULT);
|
||||
// The screen object outlives this window's own widgets (lvgl-window-manager deletes and
|
||||
// recreates only the topmost window's widget on every app switch, not the screen itself), so
|
||||
// the LV_EVENT_SIZE_CHANGED callback registered on it must be removed once buttons_wrapper is
|
||||
// destroyed, to avoid a dangling user-data pointer the next time the display rotates while a
|
||||
// different window is topmost.
|
||||
void onButtonsWrapperDeleted(lv_event_t* e) {
|
||||
auto* buttons_wrapper = lv_event_get_target_obj(e);
|
||||
auto* screen = lv_obj_get_screen(buttons_wrapper);
|
||||
lv_obj_remove_event_cb_with_user_data(screen, onButtonsWrapperResized, buttons_wrapper);
|
||||
}
|
||||
|
||||
// Ensure it's square (Material Symbols are slightly wider than tall)
|
||||
lv_obj_set_size(button_image, button_size, button_size);
|
||||
// Re-applies the flex direction and per-button margins when the display orientation changes
|
||||
// while the launcher is the visible window (these are decided once at createWidgets() based on
|
||||
// the resolution at that time, so a later rotation needs this to catch up).
|
||||
void onButtonsWrapperResized(lv_event_t* e) {
|
||||
auto* buttons_wrapper = static_cast<lv_obj_t*>(lv_event_get_user_data(e));
|
||||
const auto* display = lv_obj_get_display(buttons_wrapper);
|
||||
|
||||
lv_obj_add_event_cb(apps_button, onAppPressed, LV_EVENT_SHORT_CLICKED, (void*)appId);
|
||||
const auto button_size = lvgl_get_launcher_icon_font_height();
|
||||
const auto button_padding = getButtonPadding(lvgl_get_ui_density(), button_size);
|
||||
const auto total_button_size = button_size + (button_padding * 2);
|
||||
|
||||
return apps_button;
|
||||
const auto horizontal_px = lv_display_get_horizontal_resolution(display);
|
||||
const auto vertical_px = lv_display_get_vertical_resolution(display);
|
||||
const bool is_landscape_display = horizontal_px >= vertical_px;
|
||||
const auto current_flow = lv_obj_get_style_flex_flow(buttons_wrapper, LV_PART_MAIN);
|
||||
const bool was_landscape = current_flow == LV_FLEX_FLOW_ROW;
|
||||
if (is_landscape_display == was_landscape) {
|
||||
return;
|
||||
}
|
||||
|
||||
static void onAppPressed(lv_event_t* e) {
|
||||
auto* appId = static_cast<const char*>(lv_event_get_user_data(e));
|
||||
start(appId);
|
||||
lv_obj_set_flex_flow(buttons_wrapper, is_landscape_display ? LV_FLEX_FLOW_ROW : LV_FLEX_FLOW_COLUMN);
|
||||
|
||||
const int32_t margin = is_landscape_display
|
||||
? computeButtonMargin(horizontal_px, total_button_size)
|
||||
: computeButtonMargin(vertical_px, total_button_size);
|
||||
|
||||
const uint32_t child_count = lv_obj_get_child_count(buttons_wrapper);
|
||||
for (uint32_t i = 0; i < child_count; i++) {
|
||||
auto* button = lv_obj_get_child(buttons_wrapper, i);
|
||||
lv_obj_set_style_margin_hor(button, is_landscape_display ? margin : 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_margin_ver(button, is_landscape_display ? 0 : margin, LV_STATE_DEFAULT);
|
||||
}
|
||||
}
|
||||
|
||||
void createWidgets(lv_obj_t* parent, void*) {
|
||||
auto* buttons_wrapper = lv_obj_create(parent);
|
||||
|
||||
auto ui_density = lvgl_get_ui_density();
|
||||
const auto button_size = lvgl_get_launcher_icon_font_height();
|
||||
const auto button_padding = getButtonPadding(ui_density, button_size);
|
||||
const auto total_button_size = button_size + (button_padding * 2);
|
||||
|
||||
lv_obj_align(buttons_wrapper, LV_ALIGN_CENTER, 0, 0);
|
||||
lv_obj_set_size(buttons_wrapper, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_border_width(buttons_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_flex_grow(buttons_wrapper, 1);
|
||||
|
||||
// Fix for button selection
|
||||
lv_obj_set_style_pad_all(buttons_wrapper, 6, LV_STATE_DEFAULT);
|
||||
|
||||
const auto* display = lv_obj_get_display(parent);
|
||||
const auto horizontal_px = lv_display_get_horizontal_resolution(display);
|
||||
const auto vertical_px = lv_display_get_vertical_resolution(display);
|
||||
const bool is_landscape_display = horizontal_px >= vertical_px;
|
||||
if (is_landscape_display) {
|
||||
lv_obj_set_flex_flow(buttons_wrapper, LV_FLEX_FLOW_ROW);
|
||||
} else {
|
||||
lv_obj_set_flex_flow(buttons_wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
}
|
||||
|
||||
static bool shouldShowPowerButton() {
|
||||
bool show_power_button = false;
|
||||
device_for_each_of_type(&POWER_SUPPLY_TYPE, &show_power_button, [](Device* device, void* context) {
|
||||
if (device_is_ready(device) && power_supply_supports_power_off(device)) {
|
||||
*static_cast<bool*>(context) = true;
|
||||
return false; // stop iterating
|
||||
} else {
|
||||
return true; // continue iterating
|
||||
}
|
||||
});
|
||||
return show_power_button;
|
||||
const int32_t margin = is_landscape_display
|
||||
? computeButtonMargin(lv_display_get_horizontal_resolution(display), total_button_size)
|
||||
: computeButtonMargin(lv_display_get_vertical_resolution(display), total_button_size);
|
||||
|
||||
createAppButton(buttons_wrapper, ui_density, LVGL_ICON_LAUNCHER_APPS, "AppList", margin, is_landscape_display);
|
||||
createAppButton(buttons_wrapper, ui_density, LVGL_ICON_LAUNCHER_FOLDER, "Files", margin, is_landscape_display);
|
||||
createAppButton(buttons_wrapper, ui_density, LVGL_ICON_LAUNCHER_SETTINGS, "Settings", margin, is_landscape_display);
|
||||
|
||||
// The launcher's container is several levels below the screen, and LVGL only sends
|
||||
// LV_EVENT_SIZE_CHANGED to the screen object itself on a resolution change - so the
|
||||
// handler is attached there, with buttons_wrapper passed through as user data.
|
||||
lv_obj_add_event_cb(lv_obj_get_screen(parent), onButtonsWrapperResized, LV_EVENT_SIZE_CHANGED, buttons_wrapper);
|
||||
lv_obj_add_event_cb(buttons_wrapper, onButtonsWrapperDeleted, LV_EVENT_DELETE, nullptr);
|
||||
|
||||
// Some devices (e.g. T-Lora Pager) have no other way to power off, so the
|
||||
// button stays in the launcher; the confirmation flow lives in the PowerOff app.
|
||||
if (shouldShowPowerButton()) {
|
||||
auto* power_button = lv_button_create(parent);
|
||||
lv_obj_set_style_pad_all(power_button, 8, 0);
|
||||
lv_obj_align(power_button, LV_ALIGN_BOTTOM_MID, 0, -10);
|
||||
lv_obj_add_event_cb(power_button, onAppPressed, LV_EVENT_SHORT_CLICKED, (void*)"PowerOff");
|
||||
lv_obj_set_style_shadow_width(power_button, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_bg_opa(power_button, 0, LV_PART_MAIN);
|
||||
|
||||
auto* power_label = lv_label_create(power_button);
|
||||
lv_label_set_text(power_label, LV_SYMBOL_POWER);
|
||||
lv_obj_set_style_text_color(power_label, lv_theme_get_color_primary(parent), LV_STATE_DEFAULT);
|
||||
}
|
||||
}
|
||||
|
||||
// The screen object outlives the launcher's views (it's recreated by GuiService::redraw()
|
||||
// via lv_obj_clean() on every app switch), so the LV_EVENT_SIZE_CHANGED callback registered
|
||||
// on it must be removed once buttons_wrapper is destroyed, to avoid a dangling user-data
|
||||
// pointer on the next rotation while a different app is visible.
|
||||
static void onButtonsWrapperDeleted(lv_event_t* e) {
|
||||
auto* buttons_wrapper = lv_event_get_target_obj(e);
|
||||
auto* screen = lv_obj_get_screen(buttons_wrapper);
|
||||
lv_obj_remove_event_cb_with_user_data(screen, onButtonsWrapperResized, buttons_wrapper);
|
||||
}
|
||||
|
||||
// Re-applies the flex direction and per-button margins when the display orientation
|
||||
// changes while the launcher is the visible app (these are decided once at onShow()
|
||||
// based on the resolution at that time, so a later rotation needs this to catch up).
|
||||
static void onButtonsWrapperResized(lv_event_t* e) {
|
||||
auto* buttons_wrapper = static_cast<lv_obj_t*>(lv_event_get_user_data(e));
|
||||
const auto* display = lv_obj_get_display(buttons_wrapper);
|
||||
|
||||
const auto button_size = lvgl_get_launcher_icon_font_height();
|
||||
const auto button_padding = getButtonPadding(lvgl_get_ui_density(), button_size);
|
||||
const auto total_button_size = button_size + (button_padding * 2);
|
||||
|
||||
const auto horizontal_px = lv_display_get_horizontal_resolution(display);
|
||||
const auto vertical_px = lv_display_get_vertical_resolution(display);
|
||||
const bool is_landscape_display = horizontal_px >= vertical_px;
|
||||
const auto current_flow = lv_obj_get_style_flex_flow(buttons_wrapper, LV_PART_MAIN);
|
||||
const bool was_landscape = current_flow == LV_FLEX_FLOW_ROW;
|
||||
if (is_landscape_display == was_landscape) {
|
||||
return;
|
||||
void runAutoStart() {
|
||||
settings::BootSettings boot_properties;
|
||||
if (
|
||||
// Auto-start due to built-in requirement
|
||||
strcmp(CONFIG_TT_AUTO_START_APP_ID, "") != 0 &&
|
||||
app_manager_find_manifest(CONFIG_TT_AUTO_START_APP_ID) != nullptr
|
||||
) {
|
||||
LOG_I(TAG, "Starting %s", CONFIG_TT_AUTO_START_APP_ID);
|
||||
uint32_t app_launch_id;
|
||||
app_manager_start(CONFIG_TT_AUTO_START_APP_ID, &app_launch_id);
|
||||
} else if (
|
||||
// Auto-start due to user configuration
|
||||
settings::loadBootSettings(boot_properties) &&
|
||||
!boot_properties.autoStartAppId.empty() &&
|
||||
app_manager_find_manifest(boot_properties.autoStartAppId.c_str()) != nullptr
|
||||
) {
|
||||
LOG_I(TAG, "Starting %s", boot_properties.autoStartAppId.c_str());
|
||||
uint32_t app_launch_id;
|
||||
app_manager_start(boot_properties.autoStartAppId.c_str(), &app_launch_id);
|
||||
} else {
|
||||
// No auto-start, consider running system setup
|
||||
if (!setup::isCompleted()) {
|
||||
setup::start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lv_obj_set_flex_flow(buttons_wrapper, is_landscape_display ? LV_FLEX_FLOW_ROW : LV_FLEX_FLOW_COLUMN);
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
runAutoStart();
|
||||
|
||||
const int32_t margin = is_landscape_display
|
||||
? computeButtonMargin(horizontal_px, total_button_size)
|
||||
: computeButtonMargin(vertical_px, total_button_size);
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
const uint32_t child_count = lv_obj_get_child_count(buttons_wrapper);
|
||||
for (uint32_t i = 0; i < child_count; i++) {
|
||||
auto* button = lv_obj_get_child(buttons_wrapper, i);
|
||||
lv_obj_set_style_margin_hor(button, is_landscape_display ? margin : 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_margin_ver(button, is_landscape_display ? 0 : margin, LV_STATE_DEFAULT);
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, nullptr);
|
||||
|
||||
// The launcher is meant to stay resident (it's the home screen) - it only gives up its
|
||||
// thread when app-module's scheduler asks it to (e.g. another new-model app is started).
|
||||
while (true) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
if (event.type == APP_EVENT_CLOSE) {
|
||||
app_manager_finish(appInstanceId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
|
||||
void onCreate(AppContext& app) override {
|
||||
settings::BootSettings boot_properties;
|
||||
if (
|
||||
// Auto-start due to built-in requirement
|
||||
strcmp(CONFIG_TT_AUTO_START_APP_ID, "") != 0 &&
|
||||
findAppManifestById(CONFIG_TT_AUTO_START_APP_ID) != nullptr
|
||||
) {
|
||||
LOG_I(TAG, "Starting %s", CONFIG_TT_AUTO_START_APP_ID);
|
||||
start(CONFIG_TT_AUTO_START_APP_ID);
|
||||
} else if (
|
||||
// Auto-start due to user configuration
|
||||
settings::loadBootSettings(boot_properties) &&
|
||||
!boot_properties.autoStartAppId.empty() &&
|
||||
findAppManifestById(boot_properties.autoStartAppId) != nullptr
|
||||
) {
|
||||
LOG_I(TAG, "Starting %s", boot_properties.autoStartAppId.c_str());
|
||||
start(boot_properties.autoStartAppId);
|
||||
} else {
|
||||
// No auto-start, consider running system setup
|
||||
if (!setup::isCompleted()) {
|
||||
setup::start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override {
|
||||
auto* buttons_wrapper = lv_obj_create(parent);
|
||||
|
||||
auto ui_density = lvgl_get_ui_density();
|
||||
const auto button_size = lvgl_get_launcher_icon_font_height();
|
||||
const auto button_padding = getButtonPadding(ui_density, button_size);
|
||||
const auto total_button_size = button_size + (button_padding * 2);
|
||||
|
||||
lv_obj_align(buttons_wrapper, LV_ALIGN_CENTER, 0, 0);
|
||||
lv_obj_set_size(buttons_wrapper, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_border_width(buttons_wrapper, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_flex_grow(buttons_wrapper, 1);
|
||||
|
||||
// Fix for button selection
|
||||
lv_obj_set_style_pad_all(buttons_wrapper, 6, LV_STATE_DEFAULT);
|
||||
|
||||
const auto* display = lv_obj_get_display(parent);
|
||||
const auto horizontal_px = lv_display_get_horizontal_resolution(display);
|
||||
const auto vertical_px = lv_display_get_vertical_resolution(display);
|
||||
const bool is_landscape_display = horizontal_px >= vertical_px;
|
||||
if (is_landscape_display) {
|
||||
lv_obj_set_flex_flow(buttons_wrapper, LV_FLEX_FLOW_ROW);
|
||||
} else {
|
||||
lv_obj_set_flex_flow(buttons_wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
}
|
||||
|
||||
const int32_t margin = is_landscape_display
|
||||
? computeButtonMargin(lv_display_get_horizontal_resolution(display), total_button_size)
|
||||
: computeButtonMargin(lv_display_get_vertical_resolution(display), total_button_size);
|
||||
|
||||
createAppButton(buttons_wrapper, ui_density, LVGL_ICON_LAUNCHER_APPS, "AppList", margin, is_landscape_display);
|
||||
createAppButton(buttons_wrapper, ui_density, LVGL_ICON_LAUNCHER_FOLDER, "Files", margin, is_landscape_display);
|
||||
createAppButton(buttons_wrapper, ui_density, LVGL_ICON_LAUNCHER_SETTINGS, "Settings", margin, is_landscape_display);
|
||||
|
||||
// The launcher's container is several levels below the screen, and LVGL only sends
|
||||
// LV_EVENT_SIZE_CHANGED to the screen object itself on a resolution change - so the
|
||||
// handler is attached there, with buttons_wrapper passed through as user data.
|
||||
lv_obj_add_event_cb(lv_obj_get_screen(parent), onButtonsWrapperResized, LV_EVENT_SIZE_CHANGED, buttons_wrapper);
|
||||
lv_obj_add_event_cb(buttons_wrapper, onButtonsWrapperDeleted, LV_EVENT_DELETE, nullptr);
|
||||
|
||||
// Some devices (e.g. T-Lora Pager) have no other way to power off, so the
|
||||
// button stays in the launcher; the confirmation flow lives in the PowerOff app.
|
||||
if (shouldShowPowerButton()) {
|
||||
auto* power_button = lv_button_create(parent);
|
||||
lv_obj_set_style_pad_all(power_button, 8, 0);
|
||||
lv_obj_align(power_button, LV_ALIGN_BOTTOM_MID, 0, -10);
|
||||
lv_obj_add_event_cb(power_button, onAppPressed, LV_EVENT_SHORT_CLICKED, (void*)"PowerOff");
|
||||
lv_obj_set_style_shadow_width(power_button, 0, LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_bg_opa(power_button, 0, LV_PART_MAIN);
|
||||
|
||||
auto* power_label = lv_label_create(power_button);
|
||||
lv_label_set_text(power_label, LV_SYMBOL_POWER);
|
||||
lv_obj_set_style_text_color(power_label, lv_theme_get_color_primary(parent), LV_STATE_DEFAULT);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "Launcher",
|
||||
.appName = "Launcher",
|
||||
.appCategory = Category::System,
|
||||
.appFlags = AppManifest::Flags::Hidden,
|
||||
.createApp = create<LauncherApp>
|
||||
};
|
||||
|
||||
LaunchId start() {
|
||||
return app::start(manifest.appId);
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "Launcher",
|
||||
.name = "Launcher",
|
||||
.category = APP_CATEGORY_SYSTEM,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
|
||||
.flags = APP_MANIFEST_FLAG_HIDDEN,
|
||||
};
|
||||
|
||||
// Kept for Tactility/Private/Tactility/app/launcher/Launcher.h's existing declaration (still
|
||||
// used by the old, unconverted CrashDiagnostics app to return to the launcher after a crash).
|
||||
uint32_t start() {
|
||||
uint32_t instance_id = 0;
|
||||
app_manager_start(manifest.id, &instance_id);
|
||||
return instance_id;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
#include <Tactility/RecursiveMutex.h>
|
||||
#include <Tactility/StringUtils.h>
|
||||
#include <Tactility/app/localesettings/TextResources.h>
|
||||
#include <Tactility/lvgl/Toolbar.h>
|
||||
#include <Tactility/service/loader/Loader.h>
|
||||
#include <Tactility/settings/Language.h>
|
||||
#include <Tactility/settings/SystemSettings.h>
|
||||
|
||||
#include <lvgl/icons/shared.h>
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
|
||||
#include <lvgl.h>
|
||||
#include <map>
|
||||
@@ -23,112 +27,159 @@ constexpr auto* TEXT_RESOURCE_PATH = "/system/app/LocaleSettings/i18n";
|
||||
constexpr auto* TEXT_RESOURCE_PATH = "system/app/LocaleSettings/i18n";
|
||||
#endif
|
||||
|
||||
extern const AppManifest manifest;
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
class LocaleSettingsApp final : public App {
|
||||
namespace {
|
||||
|
||||
struct Context {
|
||||
uint32_t appInstanceId;
|
||||
tt::i18n::TextResources textResources = tt::i18n::TextResources(TEXT_RESOURCE_PATH);
|
||||
RecursiveMutex mutex;
|
||||
lv_obj_t* languageDropdown = nullptr;
|
||||
bool settingsUpdated = false;
|
||||
|
||||
std::map<settings::Language, std::string> languageMap;
|
||||
};
|
||||
|
||||
std::string getLanguageOptions() const {
|
||||
std::vector<std::string> items;
|
||||
for (int i = 0; i < static_cast<int>(settings::Language::count); i++) {
|
||||
switch (static_cast<settings::Language>(i)) {
|
||||
case settings::Language::en_GB:
|
||||
items.push_back(textResources[i18n::Text::EN_GB]);
|
||||
break;
|
||||
case settings::Language::en_US:
|
||||
items.push_back(textResources[i18n::Text::EN_US]);
|
||||
break;
|
||||
case settings::Language::fr_FR:
|
||||
items.push_back(textResources[i18n::Text::FR_FR]);
|
||||
break;
|
||||
case settings::Language::nl_BE:
|
||||
items.push_back(textResources[i18n::Text::NL_BE]);
|
||||
break;
|
||||
case settings::Language::nl_NL:
|
||||
items.push_back(textResources[i18n::Text::NL_NL]);
|
||||
break;
|
||||
case settings::Language::count:
|
||||
break;
|
||||
}
|
||||
|
||||
std::string getLanguageOptions(Context* ctx) {
|
||||
std::vector<std::string> items;
|
||||
for (int i = 0; i < static_cast<int>(settings::Language::count); i++) {
|
||||
switch (static_cast<settings::Language>(i)) {
|
||||
case settings::Language::en_GB:
|
||||
items.push_back(ctx->textResources[i18n::Text::EN_GB]);
|
||||
break;
|
||||
case settings::Language::en_US:
|
||||
items.push_back(ctx->textResources[i18n::Text::EN_US]);
|
||||
break;
|
||||
case settings::Language::fr_FR:
|
||||
items.push_back(ctx->textResources[i18n::Text::FR_FR]);
|
||||
break;
|
||||
case settings::Language::nl_BE:
|
||||
items.push_back(ctx->textResources[i18n::Text::NL_BE]);
|
||||
break;
|
||||
case settings::Language::nl_NL:
|
||||
items.push_back(ctx->textResources[i18n::Text::NL_NL]);
|
||||
break;
|
||||
case settings::Language::count:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return string::join(items, "\n");
|
||||
}
|
||||
|
||||
void updateViews(Context* ctx) {
|
||||
ctx->textResources.load();
|
||||
|
||||
std::string language_options = getLanguageOptions(ctx);
|
||||
lv_dropdown_set_options(ctx->languageDropdown, language_options.c_str());
|
||||
lv_dropdown_set_selected(ctx->languageDropdown, static_cast<uint32_t>(settings::getLanguage()));
|
||||
}
|
||||
|
||||
void onLanguageSet(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
auto index = lv_dropdown_get_selected(dropdown);
|
||||
auto language = static_cast<settings::Language>(index);
|
||||
settings::setLanguage(language);
|
||||
|
||||
updateViews(ctx);
|
||||
}
|
||||
|
||||
// Preserved from the pre-conversion code as-is: declared but never wired to any widget there
|
||||
// either, so this has always been dead code (kept verbatim rather than dropped, since removing
|
||||
// it would be a functional judgment call outside the scope of this lifecycle-only conversion).
|
||||
[[maybe_unused]] void onRegionChanged(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
ctx->settingsUpdated = true;
|
||||
}
|
||||
|
||||
void onBackPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
|
||||
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
|
||||
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
|
||||
// deadlock against itself.
|
||||
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(ctx->appInstanceId, &closeEvent);
|
||||
}
|
||||
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
ctx->textResources.load();
|
||||
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* toolbar = lvgl_toolbar_create(parent, "Region & Language");
|
||||
// The global toolbar nav callback only knows how to stop old-model apps.
|
||||
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
|
||||
|
||||
auto* main_wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_width(main_wrapper, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(main_wrapper, 1);
|
||||
|
||||
// Language
|
||||
|
||||
auto* language_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_width(language_wrapper, LV_PCT(100));
|
||||
lv_obj_set_height(language_wrapper, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(language_wrapper, 8, 0);
|
||||
lv_obj_set_style_border_width(language_wrapper, 0, 0);
|
||||
|
||||
auto* languageLabel = lv_label_create(language_wrapper);
|
||||
lv_label_set_text(languageLabel, ctx->textResources[i18n::Text::LANGUAGE].c_str());
|
||||
lv_obj_align(languageLabel, LV_ALIGN_LEFT_MID, 4, 0);
|
||||
|
||||
ctx->languageDropdown = lv_dropdown_create(language_wrapper);
|
||||
lv_obj_set_width(ctx->languageDropdown, 150);
|
||||
lv_obj_align(ctx->languageDropdown, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
std::string language_options = getLanguageOptions(ctx);
|
||||
lv_dropdown_set_options(ctx->languageDropdown, language_options.c_str());
|
||||
lv_dropdown_set_selected(ctx->languageDropdown, static_cast<uint32_t>(settings::getLanguage()));
|
||||
lv_obj_add_event_cb(ctx->languageDropdown, onLanguageSet, LV_EVENT_VALUE_CHANGED, ctx);
|
||||
}
|
||||
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
Context ctx;
|
||||
ctx.appInstanceId = appInstanceId;
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
|
||||
bool shouldClose = false;
|
||||
while (!shouldClose) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
switch (event.type) {
|
||||
case APP_EVENT_CLOSE:
|
||||
app_manager_finish(appInstanceId);
|
||||
shouldClose = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return string::join(items, "\n");
|
||||
}
|
||||
|
||||
void updateViews() {
|
||||
textResources.load();
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
std::string language_options = getLanguageOptions();
|
||||
lv_dropdown_set_options(languageDropdown, language_options.c_str());
|
||||
lv_dropdown_set_selected(languageDropdown, static_cast<uint32_t>(settings::getLanguage()));
|
||||
}
|
||||
|
||||
static void onLanguageSet(lv_event_t* event) {
|
||||
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
auto index = lv_dropdown_get_selected(dropdown);
|
||||
auto language = static_cast<settings::Language>(index);
|
||||
settings::setLanguage(language);
|
||||
|
||||
auto* self = static_cast<LocaleSettingsApp*>(lv_event_get_user_data(event));
|
||||
self->updateViews();
|
||||
}
|
||||
|
||||
static void onRegionChanged(lv_event_t* event) {
|
||||
auto* self = static_cast<LocaleSettingsApp*>(lv_event_get_user_data(event));
|
||||
self->settingsUpdated = true;
|
||||
}
|
||||
|
||||
public:
|
||||
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override {
|
||||
textResources.load();
|
||||
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
lvgl::toolbar_create(parent, app);
|
||||
|
||||
auto* main_wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_width(main_wrapper, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(main_wrapper, 1);
|
||||
|
||||
// Language
|
||||
|
||||
auto* language_wrapper = lv_obj_create(main_wrapper);
|
||||
lv_obj_set_width(language_wrapper, LV_PCT(100));
|
||||
lv_obj_set_height(language_wrapper, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(language_wrapper, 8, 0);
|
||||
lv_obj_set_style_border_width(language_wrapper, 0, 0);
|
||||
|
||||
auto* languageLabel = lv_label_create(language_wrapper);
|
||||
lv_label_set_text(languageLabel, textResources[i18n::Text::LANGUAGE].c_str());
|
||||
lv_obj_align(languageLabel, LV_ALIGN_LEFT_MID, 4, 0);
|
||||
|
||||
languageDropdown = lv_dropdown_create(language_wrapper);
|
||||
lv_obj_set_width(languageDropdown, 150);
|
||||
lv_obj_align(languageDropdown, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
std::string language_options = getLanguageOptions();
|
||||
lv_dropdown_set_options(languageDropdown, language_options.c_str());
|
||||
lv_dropdown_set_selected(languageDropdown, static_cast<uint32_t>(settings::getLanguage()));
|
||||
lv_obj_add_event_cb(languageDropdown, onLanguageSet, LV_EVENT_VALUE_CHANGED, this);
|
||||
}
|
||||
};
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "LocaleSettings",
|
||||
.appName = "Region & Language",
|
||||
.appIcon = LVGL_ICON_SHARED_LANGUAGE,
|
||||
.appCategory = Category::Settings,
|
||||
.createApp = create<LocaleSettingsApp>
|
||||
};
|
||||
|
||||
LaunchId start() {
|
||||
return app::start(manifest.appId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "LocaleSettings",
|
||||
.name = "Region & Language",
|
||||
.category = APP_CATEGORY_SETTINGS,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
|
||||
};
|
||||
|
||||
} // namespace tt::app::localesettings
|
||||
|
||||
@@ -1,228 +1,258 @@
|
||||
#include "lvgl/lvgl.h"
|
||||
|
||||
#include <Tactility/app/AppManifest.h>
|
||||
#include <Tactility/app/notes/Notes.h>
|
||||
#include <Tactility/app/fileselection/FileSelection.h>
|
||||
#include <Tactility/lvgl/Toolbar.h>
|
||||
#include <Tactility/file/File.h>
|
||||
|
||||
#include <lvgl/icons/shared.h>
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <lvgl.h>
|
||||
#include <lvgl/lvgl.h>
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
#include <tactility/log.h>
|
||||
|
||||
namespace tt::app::notes {
|
||||
|
||||
constexpr auto* TAG = "Notes";
|
||||
constexpr auto* NOTES_FILE_ARGUMENT = "file";
|
||||
|
||||
class NotesApp final : public App {
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
lv_obj_t* uiCurrentFileName;
|
||||
lv_obj_t* uiDropDownMenu;
|
||||
lv_obj_t* uiNoteText;
|
||||
namespace {
|
||||
|
||||
struct Context {
|
||||
uint32_t appInstanceId;
|
||||
|
||||
lv_obj_t* uiCurrentFileName = nullptr;
|
||||
lv_obj_t* uiDropDownMenu = nullptr;
|
||||
lv_obj_t* uiNoteText = nullptr;
|
||||
|
||||
std::string filePath;
|
||||
std::string saveBuffer;
|
||||
|
||||
LaunchId loadFileLaunchId = 0;
|
||||
LaunchId saveFileLaunchId = 0;
|
||||
|
||||
#pragma region Main_Events_Functions
|
||||
|
||||
void appNotesEventCb(lv_event_t* e) {
|
||||
lv_event_code_t code = lv_event_get_code(e);
|
||||
lv_obj_t* obj = lv_event_get_target_obj(e);
|
||||
|
||||
if (code == LV_EVENT_VALUE_CHANGED) {
|
||||
if (obj == uiDropDownMenu) {
|
||||
switch (lv_dropdown_get_selected(obj)) {
|
||||
case 0: // New
|
||||
resetFileContent();
|
||||
break;
|
||||
case 1: // Save
|
||||
if (!filePath.empty()) {
|
||||
lvgl_lock();
|
||||
saveBuffer = lv_textarea_get_text(uiNoteText);
|
||||
lvgl_unlock();
|
||||
saveFile(filePath);
|
||||
}
|
||||
break;
|
||||
case 2: // Save as...
|
||||
lvgl_lock();
|
||||
saveBuffer = lv_textarea_get_text(uiNoteText);
|
||||
lvgl_unlock();
|
||||
saveFileLaunchId = fileselection::startForExistingOrNewFile();
|
||||
LOG_I(TAG, "launched with id %u", saveFileLaunchId);
|
||||
break;
|
||||
case 3: // Load
|
||||
loadFileLaunchId = fileselection::startForExistingFile();
|
||||
LOG_I(TAG, "launched with id %u", loadFileLaunchId);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
auto* cont = lv_event_get_current_target_obj(e);
|
||||
if (obj == cont) return;
|
||||
if (lv_obj_get_child(cont, 1)) {
|
||||
saveFileLaunchId = fileselection::startForExistingOrNewFile();
|
||||
LOG_I(TAG, "launched with id %u", saveFileLaunchId);
|
||||
} else { //Reset
|
||||
resetFileContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void resetFileContent() {
|
||||
lv_textarea_set_text(uiNoteText, "");
|
||||
filePath = "";
|
||||
saveBuffer = "";
|
||||
lv_label_set_text(uiCurrentFileName, "Untitled");
|
||||
}
|
||||
|
||||
#pragma region Open_Events_Functions
|
||||
|
||||
void openFile(const std::string& path) {
|
||||
// We might be reading from the SD card, which could share a SPI bus with other devices (display)
|
||||
file::FileMutexGuard guard(path);
|
||||
auto data = file::readString(path);
|
||||
if (data != nullptr) {
|
||||
lvgl_lock();
|
||||
lv_textarea_set_text(uiNoteText, reinterpret_cast<const char*>(data.get()));
|
||||
lv_label_set_text(uiCurrentFileName, path.c_str());
|
||||
lvgl_unlock();
|
||||
filePath = path;
|
||||
LOG_I(TAG, "Loaded from %s", path.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
bool saveFile(const std::string& path) {
|
||||
// We might be writing to SD card, which could share a SPI bus with other devices (display)
|
||||
bool result = false;
|
||||
{
|
||||
file::FileMutexGuard guard(path);
|
||||
if (file::writeString(path, saveBuffer.c_str())) {
|
||||
LOG_I(TAG, "Saved to %s", path.c_str());
|
||||
filePath = path;
|
||||
result = true;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
#pragma endregion Open_Events_Functions
|
||||
|
||||
void onCreate(AppContext& appContext) override {
|
||||
auto parameters = appContext.getParameters();
|
||||
std::string file_path;
|
||||
if (parameters != nullptr && parameters->optString(NOTES_FILE_ARGUMENT, file_path)) {
|
||||
if (!file_path.empty()) {
|
||||
filePath = file_path;
|
||||
}
|
||||
}
|
||||
}
|
||||
void onShow(AppContext& context, lv_obj_t* parent) override {
|
||||
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
lv_obj_t* toolbar = lvgl::toolbar_create(parent, context);
|
||||
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||
|
||||
uiDropDownMenu = lv_dropdown_create(toolbar);
|
||||
lv_dropdown_set_options(uiDropDownMenu, LV_SYMBOL_FILE " New File\n" LV_SYMBOL_SAVE " Save\n" LV_SYMBOL_SAVE " Save As...\n" LV_SYMBOL_DIRECTORY " Open File");
|
||||
lv_dropdown_set_text(uiDropDownMenu, "Menu");
|
||||
lv_dropdown_set_symbol(uiDropDownMenu, LV_SYMBOL_DOWN);
|
||||
lv_dropdown_set_selected_highlight(uiDropDownMenu, false);
|
||||
lv_obj_align(uiDropDownMenu, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_obj_add_event_cb(uiDropDownMenu,
|
||||
[](lv_event_t* e) {
|
||||
auto *self = static_cast<NotesApp *>(lv_event_get_user_data(e));
|
||||
self->appNotesEventCb(e);
|
||||
},
|
||||
LV_EVENT_VALUE_CHANGED,
|
||||
this
|
||||
);
|
||||
|
||||
lv_obj_t* wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
|
||||
lv_obj_set_flex_grow(wrapper, 1);
|
||||
lv_obj_set_width(wrapper, LV_PCT(100));
|
||||
lv_obj_set_height(wrapper, LV_PCT(100));
|
||||
lv_obj_set_style_pad_all(wrapper, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_row(wrapper, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(wrapper, 0, LV_PART_MAIN);
|
||||
lv_obj_remove_flag(wrapper, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
uiNoteText = lv_textarea_create(wrapper);
|
||||
lv_obj_set_width(uiNoteText, LV_PCT(100));
|
||||
lv_obj_set_height(uiNoteText, LV_PCT(86));
|
||||
lv_textarea_set_password_mode(uiNoteText, false);
|
||||
if (lv_display_get_color_format(lv_obj_get_display(parent)) != LV_COLOR_FORMAT_L8) {
|
||||
lv_obj_set_style_bg_color(uiNoteText, lv_color_hex(0x262626), LV_PART_MAIN);
|
||||
}
|
||||
lv_textarea_set_placeholder_text(uiNoteText, "Notes...");
|
||||
|
||||
lv_obj_t* footer = lv_obj_create(wrapper);
|
||||
lv_obj_set_flex_flow(footer, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(footer, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
if (lv_display_get_color_format(lv_obj_get_display(parent)) == LV_COLOR_FORMAT_L8) {
|
||||
lv_obj_set_style_bg_color(footer, lv_color_hex(0xEEEEEE), LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(footer, 1, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_color(footer, lv_theme_get_color_secondary(footer), LV_PART_MAIN);
|
||||
lv_obj_set_style_border_side(footer, LV_BORDER_SIDE_TOP, LV_PART_MAIN);
|
||||
} else {
|
||||
lv_obj_set_style_bg_color(footer, lv_color_hex(0x262626), LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(footer, 0, LV_PART_MAIN);
|
||||
}
|
||||
lv_obj_set_width(footer, LV_PCT(100));
|
||||
lv_obj_set_height(footer, LV_PCT(14));
|
||||
lv_obj_set_style_pad_all(footer, 0, LV_PART_MAIN);
|
||||
lv_obj_remove_flag(footer, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
uiCurrentFileName = lv_label_create(footer);
|
||||
lv_label_set_long_mode(uiCurrentFileName, LV_LABEL_LONG_MODE_SCROLL_CIRCULAR);
|
||||
lv_obj_set_width(uiCurrentFileName, LV_SIZE_CONTENT);
|
||||
lv_obj_set_height(uiCurrentFileName, LV_SIZE_CONTENT);
|
||||
lv_label_set_text(uiCurrentFileName, "Untitled");
|
||||
lv_obj_align(uiCurrentFileName, LV_ALIGN_CENTER, 0, 0);
|
||||
|
||||
if (!filePath.empty()) {
|
||||
openFile(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
void onResult(AppContext& appContext, LaunchId launchId, Result result, std::unique_ptr<Bundle> resultData) override {
|
||||
LOG_I(TAG, "Result for launch id %u", launchId);
|
||||
if (launchId == loadFileLaunchId) {
|
||||
loadFileLaunchId = 0;
|
||||
if (result == Result::Ok && resultData != nullptr) {
|
||||
auto path = fileselection::getResultPath(*resultData);
|
||||
openFile(path);
|
||||
}
|
||||
} else if (launchId == saveFileLaunchId) {
|
||||
saveFileLaunchId = 0;
|
||||
if (result == Result::Ok && resultData != nullptr) {
|
||||
auto path = fileselection::getResultPath(*resultData);
|
||||
// Must re-open file, because UI was cleared after opening other app
|
||||
if (saveFile(path)) {
|
||||
openFile(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
uint32_t loadFileLaunchId = 0;
|
||||
uint32_t saveFileLaunchId = 0;
|
||||
};
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "Notes",
|
||||
.appName = "Notes",
|
||||
.appIcon = LVGL_ICON_SHARED_EDIT_NOTE,
|
||||
.createApp = create<NotesApp>
|
||||
};
|
||||
|
||||
LaunchId start(const std::string& filePath) {
|
||||
auto parameters = std::make_shared<Bundle>();
|
||||
parameters->putString(NOTES_FILE_ARGUMENT, filePath);
|
||||
return app::start(manifest.appId, parameters);
|
||||
void resetFileContent(Context* ctx) {
|
||||
lv_textarea_set_text(ctx->uiNoteText, "");
|
||||
ctx->filePath = "";
|
||||
ctx->saveBuffer = "";
|
||||
lv_label_set_text(ctx->uiCurrentFileName, "Untitled");
|
||||
}
|
||||
|
||||
} // namespace tt::app::notes
|
||||
void openFile(Context* ctx, const std::string& path) {
|
||||
// We might be reading from the SD card, which could share a SPI bus with other devices (display)
|
||||
file::FileMutexGuard guard(path);
|
||||
auto data = file::readString(path);
|
||||
if (data != nullptr) {
|
||||
lvgl_lock();
|
||||
lv_textarea_set_text(ctx->uiNoteText, reinterpret_cast<const char*>(data.get()));
|
||||
lv_label_set_text(ctx->uiCurrentFileName, path.c_str());
|
||||
lvgl_unlock();
|
||||
ctx->filePath = path;
|
||||
LOG_I(TAG, "Loaded from %s", path.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
bool saveFile(Context* ctx, const std::string& path) {
|
||||
// We might be writing to SD card, which could share a SPI bus with other devices (display)
|
||||
bool result = false;
|
||||
{
|
||||
file::FileMutexGuard guard(path);
|
||||
if (file::writeString(path, ctx->saveBuffer.c_str())) {
|
||||
LOG_I(TAG, "Saved to %s", path.c_str());
|
||||
ctx->filePath = path;
|
||||
result = true;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void appNotesEventCb(lv_event_t* e) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
|
||||
lv_event_code_t code = lv_event_get_code(e);
|
||||
lv_obj_t* obj = lv_event_get_target_obj(e);
|
||||
|
||||
if (code == LV_EVENT_VALUE_CHANGED) {
|
||||
if (obj == ctx->uiDropDownMenu) {
|
||||
switch (lv_dropdown_get_selected(obj)) {
|
||||
case 0: // New
|
||||
resetFileContent(ctx);
|
||||
break;
|
||||
case 1: // Save
|
||||
if (!ctx->filePath.empty()) {
|
||||
lvgl_lock();
|
||||
ctx->saveBuffer = lv_textarea_get_text(ctx->uiNoteText);
|
||||
lvgl_unlock();
|
||||
saveFile(ctx, ctx->filePath);
|
||||
}
|
||||
break;
|
||||
case 2: // Save as...
|
||||
lvgl_lock();
|
||||
ctx->saveBuffer = lv_textarea_get_text(ctx->uiNoteText);
|
||||
lvgl_unlock();
|
||||
ctx->saveFileLaunchId = fileselection::startForExistingOrNewFile(ctx->appInstanceId);
|
||||
LOG_I(TAG, "launched with id %u", ctx->saveFileLaunchId);
|
||||
break;
|
||||
case 3: // Load
|
||||
ctx->loadFileLaunchId = fileselection::startForExistingFile(ctx->appInstanceId);
|
||||
LOG_I(TAG, "launched with id %u", ctx->loadFileLaunchId);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
auto* cont = lv_event_get_current_target_obj(e);
|
||||
if (obj == cont) return;
|
||||
if (lv_obj_get_child(cont, 1)) {
|
||||
ctx->saveFileLaunchId = fileselection::startForExistingOrNewFile(ctx->appInstanceId);
|
||||
LOG_I(TAG, "launched with id %u", ctx->saveFileLaunchId);
|
||||
} else { //Reset
|
||||
resetFileContent(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
|
||||
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Notes");
|
||||
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||
|
||||
ctx->uiDropDownMenu = lv_dropdown_create(toolbar);
|
||||
lv_dropdown_set_options(ctx->uiDropDownMenu, LV_SYMBOL_FILE " New File\n" LV_SYMBOL_SAVE " Save\n" LV_SYMBOL_SAVE " Save As...\n" LV_SYMBOL_DIRECTORY " Open File");
|
||||
lv_dropdown_set_text(ctx->uiDropDownMenu, "Menu");
|
||||
lv_dropdown_set_symbol(ctx->uiDropDownMenu, LV_SYMBOL_DOWN);
|
||||
lv_dropdown_set_selected_highlight(ctx->uiDropDownMenu, false);
|
||||
lv_obj_align(ctx->uiDropDownMenu, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_obj_add_event_cb(ctx->uiDropDownMenu, appNotesEventCb, LV_EVENT_VALUE_CHANGED, ctx);
|
||||
|
||||
lv_obj_t* wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
|
||||
lv_obj_set_flex_grow(wrapper, 1);
|
||||
lv_obj_set_width(wrapper, LV_PCT(100));
|
||||
lv_obj_set_height(wrapper, LV_PCT(100));
|
||||
lv_obj_set_style_pad_all(wrapper, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_row(wrapper, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(wrapper, 0, LV_PART_MAIN);
|
||||
lv_obj_remove_flag(wrapper, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
ctx->uiNoteText = lv_textarea_create(wrapper);
|
||||
lv_obj_set_width(ctx->uiNoteText, LV_PCT(100));
|
||||
lv_obj_set_height(ctx->uiNoteText, LV_PCT(86));
|
||||
lv_textarea_set_password_mode(ctx->uiNoteText, false);
|
||||
if (lv_display_get_color_format(lv_obj_get_display(parent)) != LV_COLOR_FORMAT_L8) {
|
||||
lv_obj_set_style_bg_color(ctx->uiNoteText, lv_color_hex(0x262626), LV_PART_MAIN);
|
||||
}
|
||||
lv_textarea_set_placeholder_text(ctx->uiNoteText, "Notes...");
|
||||
|
||||
lv_obj_t* footer = lv_obj_create(wrapper);
|
||||
lv_obj_set_flex_flow(footer, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(footer, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
if (lv_display_get_color_format(lv_obj_get_display(parent)) == LV_COLOR_FORMAT_L8) {
|
||||
lv_obj_set_style_bg_color(footer, lv_color_hex(0xEEEEEE), LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(footer, 1, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_color(footer, lv_theme_get_color_secondary(footer), LV_PART_MAIN);
|
||||
lv_obj_set_style_border_side(footer, LV_BORDER_SIDE_TOP, LV_PART_MAIN);
|
||||
} else {
|
||||
lv_obj_set_style_bg_color(footer, lv_color_hex(0x262626), LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(footer, 0, LV_PART_MAIN);
|
||||
}
|
||||
lv_obj_set_width(footer, LV_PCT(100));
|
||||
lv_obj_set_height(footer, LV_PCT(14));
|
||||
lv_obj_set_style_pad_all(footer, 0, LV_PART_MAIN);
|
||||
lv_obj_remove_flag(footer, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
ctx->uiCurrentFileName = lv_label_create(footer);
|
||||
lv_label_set_long_mode(ctx->uiCurrentFileName, LV_LABEL_LONG_MODE_SCROLL_CIRCULAR);
|
||||
lv_obj_set_width(ctx->uiCurrentFileName, LV_SIZE_CONTENT);
|
||||
lv_obj_set_height(ctx->uiCurrentFileName, LV_SIZE_CONTENT);
|
||||
lv_label_set_text(ctx->uiCurrentFileName, "Untitled");
|
||||
lv_obj_align(ctx->uiCurrentFileName, LV_ALIGN_CENTER, 0, 0);
|
||||
|
||||
if (!ctx->filePath.empty()) {
|
||||
openFile(ctx, ctx->filePath);
|
||||
}
|
||||
}
|
||||
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
|
||||
Context ctx {};
|
||||
ctx.appInstanceId = appInstanceId;
|
||||
if (argc > 0 && argv[0][0] != '\0') {
|
||||
ctx.filePath = argv[0];
|
||||
}
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
|
||||
bool shouldClose = false;
|
||||
while (!shouldClose) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
switch (event.type) {
|
||||
case APP_EVENT_CLOSE:
|
||||
app_manager_finish(appInstanceId);
|
||||
shouldClose = true;
|
||||
break;
|
||||
case APP_EVENT_RESULT:
|
||||
LOG_I(TAG, "Result for launch id %u", event.result.launch_id);
|
||||
if (event.result.launch_id == ctx.loadFileLaunchId) {
|
||||
ctx.loadFileLaunchId = 0;
|
||||
if (event.result.result == 0 /* Ok */) {
|
||||
auto path = fileselection::getLastPath();
|
||||
if (!path.empty()) {
|
||||
openFile(&ctx, path);
|
||||
}
|
||||
}
|
||||
} else if (event.result.launch_id == ctx.saveFileLaunchId) {
|
||||
ctx.saveFileLaunchId = 0;
|
||||
if (event.result.result == 0 /* Ok */) {
|
||||
auto path = fileselection::getLastPath();
|
||||
// Must re-open file, because the UI was cleared after opening the dialog.
|
||||
if (!path.empty() && saveFile(&ctx, path)) {
|
||||
openFile(&ctx, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
app_manager_stop(event.result.launch_id);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void start(const std::string& filePath) {
|
||||
const char* argv[] = { filePath.c_str() };
|
||||
uint32_t instanceId = 0;
|
||||
app_manager_start_with_parameters(manifest.id, 1, argv, &instanceId);
|
||||
}
|
||||
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "Notes",
|
||||
.name = "Notes",
|
||||
.category = APP_CATEGORY_USER,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
|
||||
};
|
||||
|
||||
} // namespace tt::app::notes
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
#include <Tactility/app/AppContext.h>
|
||||
#include <Tactility/lvgl/Style.h>
|
||||
#include <Tactility/lvgl/Toolbar.h>
|
||||
#include <Tactility/service/loader/Loader.h>
|
||||
#include <Tactility/Timer.h>
|
||||
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/power_supply.h>
|
||||
#include <tactility/time.h>
|
||||
|
||||
#include <lvgl/lvgl.h>
|
||||
#include <lvgl/icons/shared.h>
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
@@ -17,28 +20,16 @@ namespace tt::app::power {
|
||||
|
||||
#define TAG "power"
|
||||
|
||||
extern const AppManifest manifest;
|
||||
|
||||
class PowerApp;
|
||||
|
||||
/** Returns the app data if the app is active. Note that this could clash if the same app is started twice and a background thread is slow. */
|
||||
std::shared_ptr<PowerApp> optApp() {
|
||||
auto appContext = getCurrentAppContext();
|
||||
if (appContext != nullptr && appContext->getManifest().appId == manifest.appId) {
|
||||
return std::static_pointer_cast<PowerApp>(appContext->getApp());
|
||||
} else {
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr PowerSupplyProperty DISPLAYED_PROPERTIES[] = {
|
||||
POWER_SUPPLY_PROP_IS_CHARGING,
|
||||
POWER_SUPPLY_PROP_VOLTAGE,
|
||||
POWER_SUPPLY_PROP_CAPACITY,
|
||||
POWER_SUPPLY_PROP_CURRENT,
|
||||
};
|
||||
} // namespace
|
||||
|
||||
struct PropertyWidget {
|
||||
PowerSupplyProperty property;
|
||||
@@ -52,212 +43,240 @@ struct DeviceEntry {
|
||||
std::vector<PropertyWidget> propertyWidgets;
|
||||
};
|
||||
|
||||
class PowerApp : public App {
|
||||
|
||||
Timer update_timer = Timer(Timer::Type::Periodic, millis_to_ticks(1000),[]() { onTimer(); });
|
||||
|
||||
struct Context {
|
||||
uint32_t appInstanceId;
|
||||
std::unique_ptr<Timer> timer;
|
||||
std::vector<DeviceEntry> entries;
|
||||
|
||||
static void onTimer() {
|
||||
auto app = optApp();
|
||||
if (app != nullptr) {
|
||||
app->updateUi();
|
||||
}
|
||||
}
|
||||
|
||||
static bool collectDevice(::Device* device, void* context) {
|
||||
auto* devices = static_cast<std::vector<::Device*>*>(context);
|
||||
devices->push_back(device);
|
||||
return true;
|
||||
}
|
||||
|
||||
void onPowerEnabledChanged(lv_event_t* event) {
|
||||
lv_event_code_t code = lv_event_get_code(event);
|
||||
auto* enable_switch = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
if (code == LV_EVENT_VALUE_CHANGED) {
|
||||
bool is_on = lv_obj_has_state(enable_switch, LV_STATE_CHECKED);
|
||||
auto* device = static_cast<::Device*>(lv_event_get_user_data(event));
|
||||
|
||||
if (power_supply_is_allowed_to_charge(device) != is_on) {
|
||||
power_supply_set_allowed_to_charge(device, is_on);
|
||||
updateUi();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void onPowerEnabledChangedCallback(lv_event_t* event) {
|
||||
auto app = optApp();
|
||||
if (app != nullptr) {
|
||||
app->onPowerEnabledChanged(event);
|
||||
}
|
||||
}
|
||||
|
||||
void onQuickChargeChanged(lv_event_t* event) {
|
||||
lv_event_code_t code = lv_event_get_code(event);
|
||||
auto* qc_switch = static_cast<lv_obj_t*>(lv_event_get_target(event));
|
||||
if (code == LV_EVENT_VALUE_CHANGED) {
|
||||
bool is_on = lv_obj_has_state(qc_switch, LV_STATE_CHECKED);
|
||||
auto* device = static_cast<::Device*>(lv_event_get_user_data(event));
|
||||
|
||||
if (power_supply_is_quick_charge_enabled(device) != is_on) {
|
||||
power_supply_set_quick_charge_enabled(device, is_on);
|
||||
updateUi();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void onQuickChargeChangedCallback(lv_event_t* event) {
|
||||
auto app = optApp();
|
||||
if (app != nullptr) {
|
||||
app->onQuickChargeChanged(event);
|
||||
}
|
||||
}
|
||||
|
||||
static void setPropertyLabelText(lv_obj_t* label, PowerSupplyProperty property, const PowerSupplyPropertyValue& value) {
|
||||
switch (property) {
|
||||
case POWER_SUPPLY_PROP_IS_CHARGING:
|
||||
lv_label_set_text_fmt(label, "Charging: %s", value.int_value ? "yes" : "no");
|
||||
break;
|
||||
case POWER_SUPPLY_PROP_VOLTAGE:
|
||||
lv_label_set_text_fmt(label, "Battery voltage: %d mV", value.int_value);
|
||||
break;
|
||||
case POWER_SUPPLY_PROP_CAPACITY:
|
||||
lv_label_set_text_fmt(label, "Charge level: %d%%", value.int_value);
|
||||
break;
|
||||
case POWER_SUPPLY_PROP_CURRENT:
|
||||
lv_label_set_text_fmt(label, "Current: %d mA", value.int_value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void updateUi() {
|
||||
if (entries.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
lvgl_lock();
|
||||
|
||||
for (auto& entry : entries) {
|
||||
if (entry.enableSwitch != nullptr) {
|
||||
lv_obj_set_state(entry.enableSwitch, LV_STATE_CHECKED, power_supply_is_allowed_to_charge(entry.device));
|
||||
}
|
||||
|
||||
if (entry.quickChargeSwitch != nullptr) {
|
||||
lv_obj_set_state(entry.quickChargeSwitch, LV_STATE_CHECKED, power_supply_is_quick_charge_enabled(entry.device));
|
||||
}
|
||||
|
||||
PowerSupplyPropertyValue value;
|
||||
for (auto& widget : entry.propertyWidgets) {
|
||||
if (power_supply_get_property(entry.device, widget.property, &value) == ERROR_NONE) {
|
||||
setPropertyLabelText(widget.label, widget.property, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lvgl_unlock();
|
||||
}
|
||||
|
||||
public:
|
||||
|
||||
void onCreate(AppContext& app) override {}
|
||||
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override {
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
lvgl::toolbar_create(parent, app);
|
||||
|
||||
std::vector<::Device*> devices;
|
||||
device_for_each_of_type(&POWER_SUPPLY_TYPE, &devices, collectDevice);
|
||||
|
||||
if (devices.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
lv_obj_t* wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_width(wrapper, LV_PCT(100));
|
||||
lv_obj_set_style_border_width(wrapper, 0, 0);
|
||||
lv_obj_set_flex_grow(wrapper, 1);
|
||||
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
|
||||
entries.clear();
|
||||
entries.reserve(devices.size());
|
||||
|
||||
for (size_t i = 0; i < devices.size(); i++) {
|
||||
::Device* device = devices[i];
|
||||
|
||||
DeviceEntry entry;
|
||||
entry.device = device;
|
||||
|
||||
lv_obj_t* header = lv_label_create(wrapper);
|
||||
lv_label_set_text_fmt(header, "%s:", device->name);
|
||||
|
||||
if (power_supply_supports_charge_control(device)) {
|
||||
lv_obj_t* switch_container = lv_obj_create(wrapper);
|
||||
lv_obj_set_width(switch_container, LV_PCT(100));
|
||||
lv_obj_set_height(switch_container, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(switch_container, 0, 0);
|
||||
lv_obj_set_style_pad_gap(switch_container, 0, 0);
|
||||
lvgl::obj_set_style_bg_invisible(switch_container);
|
||||
|
||||
lv_obj_t* label = lv_label_create(switch_container);
|
||||
lv_label_set_text(label, "Charging enabled");
|
||||
lv_obj_set_align(label, LV_ALIGN_LEFT_MID);
|
||||
|
||||
lv_obj_t* enable_switch = lv_switch_create(switch_container);
|
||||
lv_obj_add_event_cb(enable_switch, onPowerEnabledChangedCallback, LV_EVENT_VALUE_CHANGED, device);
|
||||
lv_obj_set_align(enable_switch, LV_ALIGN_RIGHT_MID);
|
||||
lv_obj_set_state(enable_switch, LV_STATE_CHECKED, power_supply_is_allowed_to_charge(device));
|
||||
entry.enableSwitch = enable_switch;
|
||||
}
|
||||
|
||||
if (power_supply_supports_quick_charge(device)) {
|
||||
lv_obj_t* qc_container = lv_obj_create(wrapper);
|
||||
lv_obj_set_width(qc_container, LV_PCT(100));
|
||||
lv_obj_set_height(qc_container, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(qc_container, 0, 0);
|
||||
lv_obj_set_style_pad_gap(qc_container, 0, 0);
|
||||
lvgl::obj_set_style_bg_invisible(qc_container);
|
||||
|
||||
lv_obj_t* label = lv_label_create(qc_container);
|
||||
lv_label_set_text(label, "Quick charge");
|
||||
lv_obj_set_align(label, LV_ALIGN_LEFT_MID);
|
||||
|
||||
lv_obj_t* qc_switch = lv_switch_create(qc_container);
|
||||
lv_obj_add_event_cb(qc_switch, onQuickChargeChangedCallback, LV_EVENT_VALUE_CHANGED, device);
|
||||
lv_obj_set_align(qc_switch, LV_ALIGN_RIGHT_MID);
|
||||
lv_obj_set_state(qc_switch, LV_STATE_CHECKED, power_supply_is_quick_charge_enabled(device));
|
||||
entry.quickChargeSwitch = qc_switch;
|
||||
}
|
||||
|
||||
PowerSupplyPropertyValue value;
|
||||
for (auto property : DISPLAYED_PROPERTIES) {
|
||||
if (power_supply_get_property(device, property, &value) == ERROR_NONE) {
|
||||
lv_obj_t* label = lv_label_create(wrapper);
|
||||
lv_obj_set_style_margin_left(label, 24, LV_STATE_DEFAULT);
|
||||
setPropertyLabelText(label, property, value);
|
||||
entry.propertyWidgets.push_back({ property, label });
|
||||
}
|
||||
}
|
||||
|
||||
entries.push_back(entry);
|
||||
}
|
||||
|
||||
update_timer.start();
|
||||
}
|
||||
|
||||
void onHide(AppContext& app) override {
|
||||
update_timer.stop();
|
||||
entries.clear();
|
||||
}
|
||||
};
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "Power",
|
||||
.appName = "Power",
|
||||
.appIcon = LVGL_ICON_SHARED_ELECTRIC_BOLT,
|
||||
.appCategory = Category::Settings,
|
||||
.createApp = create<PowerApp>
|
||||
|
||||
bool collectDevice(::Device* device, void* context) {
|
||||
auto* devices = static_cast<std::vector<::Device*>*>(context);
|
||||
devices->push_back(device);
|
||||
return true;
|
||||
}
|
||||
|
||||
void setPropertyLabelText(lv_obj_t* label, PowerSupplyProperty property, const PowerSupplyPropertyValue& value) {
|
||||
switch (property) {
|
||||
case POWER_SUPPLY_PROP_IS_CHARGING:
|
||||
lv_label_set_text_fmt(label, "Charging: %s", value.int_value ? "yes" : "no");
|
||||
break;
|
||||
case POWER_SUPPLY_PROP_VOLTAGE:
|
||||
lv_label_set_text_fmt(label, "Battery voltage: %d mV", value.int_value);
|
||||
break;
|
||||
case POWER_SUPPLY_PROP_CAPACITY:
|
||||
lv_label_set_text_fmt(label, "Charge level: %d%%", value.int_value);
|
||||
break;
|
||||
case POWER_SUPPLY_PROP_CURRENT:
|
||||
lv_label_set_text_fmt(label, "Current: %d mA", value.int_value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void updateUi(Context* ctx) {
|
||||
if (ctx->entries.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
lvgl_lock();
|
||||
|
||||
for (auto& entry : ctx->entries) {
|
||||
if (entry.enableSwitch != nullptr) {
|
||||
lv_obj_set_state(entry.enableSwitch, LV_STATE_CHECKED, power_supply_is_allowed_to_charge(entry.device));
|
||||
}
|
||||
|
||||
if (entry.quickChargeSwitch != nullptr) {
|
||||
lv_obj_set_state(entry.quickChargeSwitch, LV_STATE_CHECKED, power_supply_is_quick_charge_enabled(entry.device));
|
||||
}
|
||||
|
||||
PowerSupplyPropertyValue value;
|
||||
for (auto& widget : entry.propertyWidgets) {
|
||||
if (power_supply_get_property(entry.device, widget.property, &value) == ERROR_NONE) {
|
||||
setPropertyLabelText(widget.label, widget.property, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lvgl_unlock();
|
||||
}
|
||||
|
||||
void onBackPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
|
||||
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
|
||||
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
|
||||
// deadlock against itself.
|
||||
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(ctx->appInstanceId, &closeEvent);
|
||||
}
|
||||
|
||||
void onPowerEnabledChanged(lv_event_t* event) {
|
||||
lv_event_code_t code = lv_event_get_code(event);
|
||||
auto* enable_switch = lv_event_get_target_obj(event);
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
auto* device = static_cast<::Device*>(lv_obj_get_user_data(enable_switch));
|
||||
if (code == LV_EVENT_VALUE_CHANGED) {
|
||||
bool is_on = lv_obj_has_state(enable_switch, LV_STATE_CHECKED);
|
||||
|
||||
if (power_supply_is_allowed_to_charge(device) != is_on) {
|
||||
power_supply_set_allowed_to_charge(device, is_on);
|
||||
updateUi(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void onQuickChargeChanged(lv_event_t* event) {
|
||||
lv_event_code_t code = lv_event_get_code(event);
|
||||
auto* qc_switch = lv_event_get_target_obj(event);
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
auto* device = static_cast<::Device*>(lv_obj_get_user_data(qc_switch));
|
||||
if (code == LV_EVENT_VALUE_CHANGED) {
|
||||
bool is_on = lv_obj_has_state(qc_switch, LV_STATE_CHECKED);
|
||||
|
||||
if (power_supply_is_quick_charge_enabled(device) != is_on) {
|
||||
power_supply_set_quick_charge_enabled(device, is_on);
|
||||
updateUi(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* toolbar = lvgl_toolbar_create(parent, "Power");
|
||||
// The global toolbar nav callback only knows how to stop old-model apps.
|
||||
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
|
||||
|
||||
std::vector<::Device*> devices;
|
||||
device_for_each_of_type(&POWER_SUPPLY_TYPE, &devices, collectDevice);
|
||||
|
||||
if (devices.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
lv_obj_t* wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_width(wrapper, LV_PCT(100));
|
||||
lv_obj_set_style_border_width(wrapper, 0, 0);
|
||||
lv_obj_set_flex_grow(wrapper, 1);
|
||||
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
|
||||
ctx->entries.clear();
|
||||
ctx->entries.reserve(devices.size());
|
||||
|
||||
for (size_t i = 0; i < devices.size(); i++) {
|
||||
::Device* device = devices[i];
|
||||
|
||||
DeviceEntry entry;
|
||||
entry.device = device;
|
||||
|
||||
lv_obj_t* header = lv_label_create(wrapper);
|
||||
lv_label_set_text_fmt(header, "%s:", device->name);
|
||||
|
||||
if (power_supply_supports_charge_control(device)) {
|
||||
lv_obj_t* switch_container = lv_obj_create(wrapper);
|
||||
lv_obj_set_width(switch_container, LV_PCT(100));
|
||||
lv_obj_set_height(switch_container, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(switch_container, 0, 0);
|
||||
lv_obj_set_style_pad_gap(switch_container, 0, 0);
|
||||
lvgl::obj_set_style_bg_invisible(switch_container);
|
||||
|
||||
lv_obj_t* label = lv_label_create(switch_container);
|
||||
lv_label_set_text(label, "Charging enabled");
|
||||
lv_obj_set_align(label, LV_ALIGN_LEFT_MID);
|
||||
|
||||
lv_obj_t* enable_switch = lv_switch_create(switch_container);
|
||||
lv_obj_set_user_data(enable_switch, device);
|
||||
lv_obj_add_event_cb(enable_switch, onPowerEnabledChanged, LV_EVENT_VALUE_CHANGED, ctx);
|
||||
lv_obj_set_align(enable_switch, LV_ALIGN_RIGHT_MID);
|
||||
lv_obj_set_state(enable_switch, LV_STATE_CHECKED, power_supply_is_allowed_to_charge(device));
|
||||
entry.enableSwitch = enable_switch;
|
||||
}
|
||||
|
||||
if (power_supply_supports_quick_charge(device)) {
|
||||
lv_obj_t* qc_container = lv_obj_create(wrapper);
|
||||
lv_obj_set_width(qc_container, LV_PCT(100));
|
||||
lv_obj_set_height(qc_container, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(qc_container, 0, 0);
|
||||
lv_obj_set_style_pad_gap(qc_container, 0, 0);
|
||||
lvgl::obj_set_style_bg_invisible(qc_container);
|
||||
|
||||
lv_obj_t* label = lv_label_create(qc_container);
|
||||
lv_label_set_text(label, "Quick charge");
|
||||
lv_obj_set_align(label, LV_ALIGN_LEFT_MID);
|
||||
|
||||
lv_obj_t* qc_switch = lv_switch_create(qc_container);
|
||||
lv_obj_set_user_data(qc_switch, device);
|
||||
lv_obj_add_event_cb(qc_switch, onQuickChargeChanged, LV_EVENT_VALUE_CHANGED, ctx);
|
||||
lv_obj_set_align(qc_switch, LV_ALIGN_RIGHT_MID);
|
||||
lv_obj_set_state(qc_switch, LV_STATE_CHECKED, power_supply_is_quick_charge_enabled(device));
|
||||
entry.quickChargeSwitch = qc_switch;
|
||||
}
|
||||
|
||||
PowerSupplyPropertyValue value;
|
||||
for (auto property : DISPLAYED_PROPERTIES) {
|
||||
if (power_supply_get_property(device, property, &value) == ERROR_NONE) {
|
||||
lv_obj_t* label = lv_label_create(wrapper);
|
||||
lv_obj_set_style_margin_left(label, 24, LV_STATE_DEFAULT);
|
||||
setPropertyLabelText(label, property, value);
|
||||
entry.propertyWidgets.push_back({ property, label });
|
||||
}
|
||||
}
|
||||
|
||||
ctx->entries.push_back(entry);
|
||||
}
|
||||
}
|
||||
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
Context ctx {};
|
||||
ctx.appInstanceId = appInstanceId;
|
||||
|
||||
// Runs for this app instance's whole lifetime, mirroring GpsSettings/SystemInfo - there's no
|
||||
// push notification for power-supply property changes, so this is the only way this screen
|
||||
// finds out about them.
|
||||
ctx.timer = std::make_unique<Timer>(Timer::Type::Periodic, millis_to_ticks(1000), [&ctx] {
|
||||
updateUi(&ctx);
|
||||
});
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
ctx.timer->start();
|
||||
|
||||
bool shouldClose = false;
|
||||
while (!shouldClose) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
switch (event.type) {
|
||||
case APP_EVENT_CLOSE:
|
||||
app_manager_finish(appInstanceId);
|
||||
shouldClose = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.timer->stop();
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "Power",
|
||||
.name = "Power",
|
||||
.category = APP_CATEGORY_SETTINGS,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
#include "Tactility/Tactility.h"
|
||||
#include "tactility/drivers/display.h"
|
||||
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <Tactility/app/AppContext.h>
|
||||
#include <Tactility/app/AppRegistration.h>
|
||||
#include <Tactility/service/loader/Loader.h>
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <lvgl/icons/shared.h>
|
||||
#include <lvgl.h>
|
||||
#include <lvgl/fonts.h>
|
||||
#include <tactility/device.h>
|
||||
@@ -14,121 +14,164 @@
|
||||
|
||||
namespace tt::app::poweroff {
|
||||
|
||||
extern const AppManifest manifest;
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
class PowerOffApp final : public App {
|
||||
namespace {
|
||||
|
||||
static void showPoweredOffScreen() {
|
||||
auto* screen = lv_obj_create(nullptr);
|
||||
lv_obj_set_style_bg_color(screen, lv_color_white(), 0);
|
||||
lv_obj_set_flex_flow(screen, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(screen, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
struct Context {
|
||||
uint32_t appInstanceId;
|
||||
};
|
||||
|
||||
auto* title = lv_label_create(screen);
|
||||
lv_label_set_text(title, "Tactility");
|
||||
lv_obj_set_style_text_font(title, lvgl_get_text_font(FONT_SIZE_LARGE), 0);
|
||||
lv_obj_set_style_text_color(title, lv_color_black(), 0);
|
||||
|
||||
auto* subtitle = lv_label_create(screen);
|
||||
lv_label_set_text(subtitle, "Powered off");
|
||||
lv_obj_set_style_text_color(subtitle, lv_color_black(), 0);
|
||||
void showPoweredOffScreen() {
|
||||
auto* screen = lv_obj_create(nullptr);
|
||||
lv_obj_set_style_bg_color(screen, lv_color_white(), 0);
|
||||
lv_obj_set_flex_flow(screen, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(screen, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
|
||||
lv_screen_load(screen);
|
||||
auto* title = lv_label_create(screen);
|
||||
lv_label_set_text(title, "Tactility");
|
||||
lv_obj_set_style_text_font(title, lvgl_get_text_font(FONT_SIZE_LARGE), 0);
|
||||
lv_obj_set_style_text_color(title, lv_color_black(), 0);
|
||||
|
||||
auto* subtitle = lv_label_create(screen);
|
||||
lv_label_set_text(subtitle, "Powered off");
|
||||
lv_obj_set_style_text_color(subtitle, lv_color_black(), 0);
|
||||
|
||||
lv_screen_load(screen);
|
||||
}
|
||||
|
||||
bool anyDeviceSupportsPowerOff() {
|
||||
bool any_supported = false;
|
||||
device_for_each_of_type(&POWER_SUPPLY_TYPE, &any_supported, [](Device* device, void* context) {
|
||||
if (device_is_ready(device) && power_supply_supports_power_off(device)) {
|
||||
*static_cast<bool*>(context) = true;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return any_supported;
|
||||
}
|
||||
|
||||
void onYesPressed(lv_event_t* /*event*/) {
|
||||
if (!anyDeviceSupportsPowerOff()) {
|
||||
return;
|
||||
}
|
||||
|
||||
static bool anyDeviceSupportsPowerOff() {
|
||||
bool any_supported = false;
|
||||
device_for_each_of_type(&POWER_SUPPLY_TYPE, &any_supported, [](Device* device, void* context) {
|
||||
Device* display;
|
||||
error_t error = device_get_first_by_type(&DISPLAY_TYPE, &display);
|
||||
// TODO: remove this logic path when all displays have been migrated to kernel display drivers
|
||||
if (error != ERROR_NONE) {
|
||||
// No display, power off now
|
||||
device_for_each_of_type(&POWER_SUPPLY_TYPE, nullptr, [](Device* device, void* /*context*/) {
|
||||
if (device_is_ready(device) && power_supply_supports_power_off(device)) {
|
||||
*static_cast<bool*>(context) = true;
|
||||
return false;
|
||||
power_supply_power_off(device);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return any_supported;
|
||||
return;
|
||||
}
|
||||
|
||||
static void onYesPressed(lv_event_t* /*event*/) {
|
||||
if (!anyDeviceSupportsPowerOff()) {
|
||||
return;
|
||||
bool is_slow_refresh = display_has_capability(display, DISPLAY_CAPABILITY_SLOW_REFRESH);
|
||||
if (is_slow_refresh) {
|
||||
auto* lvgl_display = lv_display_get_default();
|
||||
showPoweredOffScreen();
|
||||
if (lvgl_display != nullptr) {
|
||||
lv_refr_now(lvgl_display);
|
||||
}
|
||||
}
|
||||
|
||||
Device* display;
|
||||
error_t error = device_get_first_by_type(&DISPLAY_TYPE, &display);
|
||||
// TODO: remove this logic path when all displays have been migrated to kernel display drivers
|
||||
if (error != ERROR_NONE) {
|
||||
// No display, power off now
|
||||
device_for_each_of_type(&POWER_SUPPLY_TYPE, nullptr, [](Device* device, void* /*context*/) {
|
||||
if (device_is_ready(device) && power_supply_supports_power_off(device)) {
|
||||
power_supply_power_off(device);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
bool is_slow_refresh = display_has_capability(display, DISPLAY_CAPABILITY_SLOW_REFRESH);
|
||||
getMainDispatcher().dispatch([is_slow_refresh] {
|
||||
// Not necessary for LilyGO Paper S3, but other drivers with async rendering might need us to wait a bit.
|
||||
if (is_slow_refresh) {
|
||||
auto* lvgl_display = lv_display_get_default();
|
||||
showPoweredOffScreen();
|
||||
if (lvgl_display != nullptr) {
|
||||
lv_refr_now(lvgl_display);
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(2000));
|
||||
}
|
||||
|
||||
getMainDispatcher().dispatch([is_slow_refresh] {
|
||||
// Not necessary for LilyGO Paper S3, but other drivers with async rendering might need us to wait a bit.
|
||||
if (is_slow_refresh) {
|
||||
vTaskDelay(pdMS_TO_TICKS(2000));
|
||||
device_for_each_of_type(&POWER_SUPPLY_TYPE, nullptr, [](Device* device, void* /*context*/) {
|
||||
if (device_is_ready(device) && power_supply_supports_power_off(device)) {
|
||||
power_supply_power_off(device);
|
||||
}
|
||||
device_for_each_of_type(&POWER_SUPPLY_TYPE, nullptr, [](Device* device, void* /*context*/) {
|
||||
if (device_is_ready(device) && power_supply_supports_power_off(device)) {
|
||||
power_supply_power_off(device);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void onNoPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
|
||||
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
|
||||
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
|
||||
// deadlock against itself.
|
||||
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(ctx->appInstanceId, &closeEvent);
|
||||
}
|
||||
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(parent, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
|
||||
auto* label = lv_label_create(parent);
|
||||
lv_label_set_text(label, "Power off?");
|
||||
lv_obj_set_style_text_font(label, lvgl_get_text_font(FONT_SIZE_LARGE), 0);
|
||||
|
||||
auto* button_wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_flex_flow(button_wrapper, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_size(button_wrapper, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_border_width(button_wrapper, 0, 0);
|
||||
lv_obj_set_flex_align(button_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
|
||||
auto* yes_button = lv_button_create(button_wrapper);
|
||||
auto* yes_label = lv_label_create(yes_button);
|
||||
lv_label_set_text(yes_label, "Yes");
|
||||
lv_obj_add_event_cb(yes_button, onYesPressed, LV_EVENT_SHORT_CLICKED, nullptr);
|
||||
|
||||
auto* no_button = lv_button_create(button_wrapper);
|
||||
auto* no_label = lv_label_create(no_button);
|
||||
lv_label_set_text(no_label, "No");
|
||||
lv_obj_add_event_cb(no_button, onNoPressed, LV_EVENT_SHORT_CLICKED, ctx);
|
||||
}
|
||||
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
Context ctx {};
|
||||
ctx.appInstanceId = appInstanceId;
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
|
||||
bool shouldClose = false;
|
||||
while (!shouldClose) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
switch (event.type) {
|
||||
case APP_EVENT_CLOSE:
|
||||
app_manager_finish(appInstanceId);
|
||||
shouldClose = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static void onNoPressed(lv_event_t* /*event*/) {
|
||||
stop(manifest.appId);
|
||||
}
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
public:
|
||||
return 0;
|
||||
}
|
||||
|
||||
void onShow(AppContext&, lv_obj_t* parent) override {
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(parent, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
} // namespace
|
||||
|
||||
auto* label = lv_label_create(parent);
|
||||
lv_label_set_text(label, "Power off?");
|
||||
lv_obj_set_style_text_font(label, lvgl_get_text_font(FONT_SIZE_LARGE), 0);
|
||||
|
||||
auto* button_wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_flex_flow(button_wrapper, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_size(button_wrapper, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_border_width(button_wrapper, 0, 0);
|
||||
lv_obj_set_flex_align(button_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
|
||||
auto* yes_button = lv_button_create(button_wrapper);
|
||||
auto* yes_label = lv_label_create(yes_button);
|
||||
lv_label_set_text(yes_label, "Yes");
|
||||
lv_obj_add_event_cb(yes_button, onYesPressed, LV_EVENT_SHORT_CLICKED, nullptr);
|
||||
|
||||
auto* no_button = lv_button_create(button_wrapper);
|
||||
auto* no_label = lv_label_create(no_button);
|
||||
lv_label_set_text(no_label, "No");
|
||||
lv_obj_add_event_cb(no_button, onNoPressed, LV_EVENT_SHORT_CLICKED, nullptr);
|
||||
}
|
||||
};
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "PowerOff",
|
||||
.appName = "Power Off",
|
||||
.appIcon = LVGL_ICON_SHARED_POWER_SETTINGS_NEW,
|
||||
.appCategory = Category::System,
|
||||
.appFlags = AppManifest::Flags::HideStatusBar | AppManifest::Flags::Hidden,
|
||||
.createApp = create<PowerOffApp>
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "PowerOff",
|
||||
.name = "Power Off",
|
||||
.category = APP_CATEGORY_SYSTEM,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
|
||||
.flags = APP_MANIFEST_FLAG_HIDDEN,
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -4,100 +4,77 @@
|
||||
#if TT_FEATURE_SCREENSHOT_ENABLED
|
||||
|
||||
#include <Tactility/Platform.h>
|
||||
#include <Tactility/app/App.h>
|
||||
#include <Tactility/app/AppManifest.h>
|
||||
#include <Tactility/lvgl/Lvgl.h>
|
||||
#include <Tactility/lvgl/Toolbar.h>
|
||||
#include <Tactility/service/screenshot/Screenshot.h>
|
||||
#include <Tactility/Paths.h>
|
||||
#include <Tactility/DeprecatedPaths.h>
|
||||
#include <Tactility/Timer.h>
|
||||
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <tactility/log.h>
|
||||
|
||||
#include <lvgl.h>
|
||||
#include <lvgl/lvgl.h>
|
||||
#include <lvgl/icons/shared.h>
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
|
||||
namespace tt::app::screenshot {
|
||||
|
||||
constexpr auto* TAG = "Screenshot";
|
||||
|
||||
extern const AppManifest manifest;
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
class ScreenshotApp final : public App {
|
||||
namespace {
|
||||
|
||||
struct Context {
|
||||
uint32_t appInstanceId;
|
||||
lv_obj_t* modeDropdown = nullptr;
|
||||
lv_obj_t* pathTextArea = nullptr;
|
||||
lv_obj_t* startStopButtonLabel = nullptr;
|
||||
lv_obj_t* timerWrapper = nullptr;
|
||||
lv_obj_t* delayTextArea = nullptr;
|
||||
std::unique_ptr<Timer> updateTimer;
|
||||
|
||||
void createTimerSettingsWidgets(lv_obj_t* parent);
|
||||
void createModeSettingWidgets(lv_obj_t* parent);
|
||||
void createFilePathWidgets(lv_obj_t* parent);
|
||||
|
||||
void updateScreenshotMode();
|
||||
|
||||
public:
|
||||
|
||||
ScreenshotApp();
|
||||
~ScreenshotApp() override;
|
||||
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override;
|
||||
void onStartPressed();
|
||||
void onModeSet();
|
||||
void onTimerTick();
|
||||
};
|
||||
|
||||
|
||||
/** Returns the app data if the app is active. Note that this could clash if the same app is started twice and a background thread is slow. */
|
||||
std::shared_ptr<ScreenshotApp> optApp() {
|
||||
auto appContext = getCurrentAppContext();
|
||||
if (appContext != nullptr && appContext->getManifest().appId == manifest.appId) {
|
||||
return std::static_pointer_cast<ScreenshotApp>(appContext->getApp());
|
||||
void updateScreenshotMode(Context* ctx) {
|
||||
auto service = service::screenshot::optScreenshotService();
|
||||
if (service == nullptr) {
|
||||
LOG_E(TAG, "Service not found/running");
|
||||
return;
|
||||
}
|
||||
|
||||
lv_obj_t* label = ctx->startStopButtonLabel;
|
||||
if (service->isTaskStarted()) {
|
||||
lv_label_set_text(label, "Stop");
|
||||
} else {
|
||||
return nullptr;
|
||||
lv_label_set_text(label, "Start");
|
||||
}
|
||||
|
||||
uint32_t selected = lv_dropdown_get_selected(ctx->modeDropdown);
|
||||
if (selected == 0) { // Timer
|
||||
lv_obj_remove_flag(ctx->timerWrapper, LV_OBJ_FLAG_HIDDEN);
|
||||
} else {
|
||||
lv_obj_add_flag(ctx->timerWrapper, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
static void onStartPressedCallback(lv_event_t* event) {
|
||||
auto app = optApp();
|
||||
if (app != nullptr) {
|
||||
app->onStartPressed();
|
||||
}
|
||||
void onBackPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
|
||||
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
|
||||
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
|
||||
// deadlock against itself.
|
||||
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(ctx->appInstanceId, &closeEvent);
|
||||
}
|
||||
|
||||
static void onModeSetCallback(lv_event_t* event) {
|
||||
auto app = optApp();
|
||||
if (app != nullptr) {
|
||||
app->onModeSet();
|
||||
}
|
||||
}
|
||||
void onStartPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
|
||||
ScreenshotApp::ScreenshotApp() {
|
||||
updateTimer = std::make_unique<Timer>(Timer::Type::Periodic, 500 / portTICK_PERIOD_MS, [this] {
|
||||
onTimerTick();
|
||||
});
|
||||
}
|
||||
|
||||
ScreenshotApp::~ScreenshotApp() {
|
||||
if (updateTimer->isRunning()) {
|
||||
updateTimer->stop();
|
||||
}
|
||||
}
|
||||
|
||||
void ScreenshotApp::onTimerTick() {
|
||||
if (lvgl_try_lock(500 / portTICK_PERIOD_MS)) {
|
||||
updateScreenshotMode();
|
||||
lvgl_unlock();
|
||||
}
|
||||
}
|
||||
|
||||
void ScreenshotApp::onModeSet() {
|
||||
updateScreenshotMode();
|
||||
}
|
||||
|
||||
void ScreenshotApp::onStartPressed() {
|
||||
auto service = service::screenshot::optScreenshotService();
|
||||
if (service == nullptr) {
|
||||
LOG_E(TAG, "Service not found/running");
|
||||
@@ -108,11 +85,11 @@ void ScreenshotApp::onStartPressed() {
|
||||
LOG_I(TAG, "Stop screenshot");
|
||||
service->stop();
|
||||
} else {
|
||||
uint32_t selected = lv_dropdown_get_selected(modeDropdown);
|
||||
const char* path = lv_textarea_get_text(pathTextArea);
|
||||
uint32_t selected = lv_dropdown_get_selected(ctx->modeDropdown);
|
||||
const char* path = lv_textarea_get_text(ctx->pathTextArea);
|
||||
if (selected == 0) {
|
||||
LOG_I(TAG, "Start timed screenshots");
|
||||
const char* delay_text = lv_textarea_get_text(delayTextArea);
|
||||
const char* delay_text = lv_textarea_get_text(ctx->delayTextArea);
|
||||
int delay = atoi(delay_text);
|
||||
if (delay > 0) {
|
||||
service->startTimed(path, delay, 1);
|
||||
@@ -125,33 +102,15 @@ void ScreenshotApp::onStartPressed() {
|
||||
}
|
||||
}
|
||||
|
||||
updateScreenshotMode();
|
||||
updateScreenshotMode(ctx);
|
||||
}
|
||||
|
||||
void ScreenshotApp::updateScreenshotMode() {
|
||||
auto service = service::screenshot::optScreenshotService();
|
||||
if (service == nullptr) {
|
||||
LOG_E(TAG, "Service not found/running");
|
||||
return;
|
||||
}
|
||||
|
||||
lv_obj_t* label = startStopButtonLabel;
|
||||
if (service->isTaskStarted()) {
|
||||
lv_label_set_text(label, "Stop");
|
||||
} else {
|
||||
lv_label_set_text(label, "Start");
|
||||
}
|
||||
|
||||
uint32_t selected = lv_dropdown_get_selected(modeDropdown);
|
||||
if (selected == 0) { // Timer
|
||||
lv_obj_remove_flag(timerWrapper, LV_OBJ_FLAG_HIDDEN);
|
||||
} else {
|
||||
lv_obj_add_flag(timerWrapper, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
void onModeSet(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
updateScreenshotMode(ctx);
|
||||
}
|
||||
|
||||
|
||||
void ScreenshotApp::createModeSettingWidgets(lv_obj_t* parent) {
|
||||
void createModeSettingWidgets(Context* ctx, lv_obj_t* parent) {
|
||||
auto service = service::screenshot::optScreenshotService();
|
||||
if (service == nullptr) {
|
||||
LOG_E(TAG, "Service not found/running");
|
||||
@@ -167,23 +126,23 @@ void ScreenshotApp::createModeSettingWidgets(lv_obj_t* parent) {
|
||||
lv_label_set_text(mode_label, "Mode:");
|
||||
lv_obj_align(mode_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
|
||||
modeDropdown = lv_dropdown_create(mode_wrapper);
|
||||
lv_dropdown_set_options(modeDropdown, "Timer\nApp start");
|
||||
lv_obj_align_to(modeDropdown, mode_label, LV_ALIGN_OUT_RIGHT_MID, 8, 0);
|
||||
lv_obj_add_event_cb(modeDropdown, onModeSetCallback, LV_EVENT_VALUE_CHANGED, nullptr);
|
||||
ctx->modeDropdown = lv_dropdown_create(mode_wrapper);
|
||||
lv_dropdown_set_options(ctx->modeDropdown, "Timer\nApp start");
|
||||
lv_obj_align_to(ctx->modeDropdown, mode_label, LV_ALIGN_OUT_RIGHT_MID, 8, 0);
|
||||
lv_obj_add_event_cb(ctx->modeDropdown, onModeSet, LV_EVENT_VALUE_CHANGED, ctx);
|
||||
service::screenshot::Mode mode = service->getMode();
|
||||
if (mode == service::screenshot::Mode::Apps) {
|
||||
lv_dropdown_set_selected(modeDropdown, 1);
|
||||
lv_dropdown_set_selected(ctx->modeDropdown, 1);
|
||||
}
|
||||
|
||||
auto* button = lv_button_create(mode_wrapper);
|
||||
lv_obj_align(button, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_obj_add_event_cb(button, &onStartPressedCallback, LV_EVENT_SHORT_CLICKED, nullptr);
|
||||
startStopButtonLabel = lv_label_create(button);
|
||||
lv_obj_align(startStopButtonLabel, LV_ALIGN_CENTER, 0, 0);
|
||||
lv_obj_add_event_cb(button, onStartPressed, LV_EVENT_SHORT_CLICKED, ctx);
|
||||
ctx->startStopButtonLabel = lv_label_create(button);
|
||||
lv_obj_align(ctx->startStopButtonLabel, LV_ALIGN_CENTER, 0, 0);
|
||||
}
|
||||
|
||||
void ScreenshotApp::createFilePathWidgets(lv_obj_t* parent) {
|
||||
void createFilePathWidgets(Context* ctx, lv_obj_t* parent) {
|
||||
auto* path_wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_size(path_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(path_wrapper, 0, 0);
|
||||
@@ -198,29 +157,29 @@ void ScreenshotApp::createFilePathWidgets(lv_obj_t* parent) {
|
||||
lv_label_set_text(path_label, "Path:");
|
||||
lv_obj_align(path_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
|
||||
pathTextArea = lv_textarea_create(path_wrapper);
|
||||
lv_textarea_set_one_line(pathTextArea, true);
|
||||
lv_obj_set_flex_grow(pathTextArea, 1);
|
||||
ctx->pathTextArea = lv_textarea_create(path_wrapper);
|
||||
lv_textarea_set_one_line(ctx->pathTextArea, true);
|
||||
lv_obj_set_flex_grow(ctx->pathTextArea, 1);
|
||||
if (kernel::getPlatform() == kernel::PlatformEsp) {
|
||||
std::string sdcard_path;
|
||||
if (findFirstMountedSdCardPath(sdcard_path)) {
|
||||
std::string lvgl_mount_path = lvgl::PATH_PREFIX + sdcard_path + "/screenshots";
|
||||
lv_textarea_set_text(pathTextArea, lvgl_mount_path.c_str());
|
||||
lv_textarea_set_text(ctx->pathTextArea, lvgl_mount_path.c_str());
|
||||
} else {
|
||||
lv_textarea_set_text(pathTextArea, "Error: no SD card");
|
||||
lv_textarea_set_text(ctx->pathTextArea, "Error: no SD card");
|
||||
}
|
||||
} else { // PC
|
||||
lv_textarea_set_text(pathTextArea, lvgl::PATH_PREFIX);
|
||||
lv_textarea_set_text(ctx->pathTextArea, lvgl::PATH_PREFIX);
|
||||
}
|
||||
}
|
||||
|
||||
void ScreenshotApp::createTimerSettingsWidgets(lv_obj_t* parent) {
|
||||
timerWrapper = lv_obj_create(parent);
|
||||
lv_obj_set_size(timerWrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(timerWrapper, 0, 0);
|
||||
lv_obj_set_style_border_width(timerWrapper, 0, 0);
|
||||
void createTimerSettingsWidgets(Context* ctx, lv_obj_t* parent) {
|
||||
ctx->timerWrapper = lv_obj_create(parent);
|
||||
lv_obj_set_size(ctx->timerWrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(ctx->timerWrapper, 0, 0);
|
||||
lv_obj_set_style_border_width(ctx->timerWrapper, 0, 0);
|
||||
|
||||
auto* delay_wrapper = lv_obj_create(timerWrapper);
|
||||
auto* delay_wrapper = lv_obj_create(ctx->timerWrapper);
|
||||
lv_obj_set_size(delay_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(delay_wrapper, 0, 0);
|
||||
lv_obj_set_style_border_width(delay_wrapper, 0, 0);
|
||||
@@ -234,11 +193,11 @@ void ScreenshotApp::createTimerSettingsWidgets(lv_obj_t* parent) {
|
||||
lv_label_set_text(delay_label, "Delay:");
|
||||
lv_obj_align(delay_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
|
||||
delayTextArea = lv_textarea_create(delay_wrapper);
|
||||
lv_textarea_set_one_line(delayTextArea, true);
|
||||
lv_textarea_set_accepted_chars(delayTextArea, "0123456789");
|
||||
lv_textarea_set_text(delayTextArea, "10");
|
||||
lv_obj_set_flex_grow(delayTextArea, 1);
|
||||
ctx->delayTextArea = lv_textarea_create(delay_wrapper);
|
||||
lv_textarea_set_one_line(ctx->delayTextArea, true);
|
||||
lv_textarea_set_accepted_chars(ctx->delayTextArea, "0123456789");
|
||||
lv_textarea_set_text(ctx->delayTextArea, "10");
|
||||
lv_obj_set_flex_grow(ctx->delayTextArea, 1);
|
||||
|
||||
auto* delay_unit_label_wrapper = lv_obj_create(delay_wrapper);
|
||||
lv_obj_set_style_border_width(delay_unit_label_wrapper, 0, 0);
|
||||
@@ -249,15 +208,19 @@ void ScreenshotApp::createTimerSettingsWidgets(lv_obj_t* parent) {
|
||||
lv_label_set_text(delay_unit_label, "seconds");
|
||||
}
|
||||
|
||||
void ScreenshotApp::onShow(AppContext& appContext, lv_obj_t* parent) {
|
||||
if (updateTimer->isRunning()) {
|
||||
updateTimer->stop();
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
|
||||
if (ctx->updateTimer->isRunning()) {
|
||||
ctx->updateTimer->stop();
|
||||
}
|
||||
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
auto* toolbar = lvgl::toolbar_create(parent, appContext);
|
||||
auto* toolbar = lvgl_toolbar_create(parent, "Screenshot");
|
||||
// The global toolbar nav callback only knows how to stop old-model apps.
|
||||
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
|
||||
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||
|
||||
auto* wrapper = lv_obj_create(parent);
|
||||
@@ -266,23 +229,66 @@ void ScreenshotApp::onShow(AppContext& appContext, lv_obj_t* parent) {
|
||||
lv_obj_set_style_border_width(wrapper, 0, 0);
|
||||
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
|
||||
createModeSettingWidgets(wrapper);
|
||||
createFilePathWidgets(wrapper);
|
||||
createTimerSettingsWidgets(wrapper);
|
||||
createModeSettingWidgets(ctx, wrapper);
|
||||
createFilePathWidgets(ctx, wrapper);
|
||||
createTimerSettingsWidgets(ctx, wrapper);
|
||||
|
||||
updateScreenshotMode();
|
||||
updateScreenshotMode(ctx);
|
||||
|
||||
if (!updateTimer->isRunning()) {
|
||||
updateTimer->start();
|
||||
if (!ctx->updateTimer->isRunning()) {
|
||||
ctx->updateTimer->start();
|
||||
}
|
||||
}
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "Screenshot",
|
||||
.appName = "Screenshot",
|
||||
.appIcon = LVGL_ICON_SHARED_IMAGE,
|
||||
.appCategory = Category::System,
|
||||
.createApp = create<ScreenshotApp>
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
Context ctx {};
|
||||
ctx.appInstanceId = appInstanceId;
|
||||
ctx.updateTimer = std::make_unique<Timer>(Timer::Type::Periodic, 500 / portTICK_PERIOD_MS, [&ctx] {
|
||||
if (lvgl_try_lock(500 / portTICK_PERIOD_MS)) {
|
||||
updateScreenshotMode(&ctx);
|
||||
lvgl_unlock();
|
||||
}
|
||||
});
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
|
||||
bool shouldClose = false;
|
||||
while (!shouldClose) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
switch (event.type) {
|
||||
case APP_EVENT_CLOSE:
|
||||
app_manager_finish(appInstanceId);
|
||||
shouldClose = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.updateTimer->isRunning()) {
|
||||
ctx.updateTimer->stop();
|
||||
}
|
||||
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "Screenshot",
|
||||
.name = "Screenshot",
|
||||
.category = APP_CATEGORY_SYSTEM,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -1,119 +1,160 @@
|
||||
#include <Tactility/app/selectiondialog/SelectionDialog.h>
|
||||
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
#include <Tactility/service/loader/Loader.h>
|
||||
#include <Tactility/StringUtils.h>
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <tactility/log.h>
|
||||
|
||||
#include <lvgl.h>
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
|
||||
namespace tt::app::selectiondialog {
|
||||
|
||||
constexpr auto* PARAMETER_BUNDLE_KEY_TITLE = "title";
|
||||
constexpr auto* PARAMETER_BUNDLE_KEY_ITEMS = "items";
|
||||
constexpr auto* RESULT_BUNDLE_KEY_INDEX = "index";
|
||||
|
||||
constexpr auto* PARAMETER_ITEM_CONCATENATION_TOKEN = ";;";
|
||||
constexpr auto* TAG = "SelectionDialog";
|
||||
constexpr auto* DEFAULT_TITLE = "Select...";
|
||||
|
||||
constexpr auto* TAG = "SelectionDialog";
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
extern const AppManifest manifest;
|
||||
namespace {
|
||||
|
||||
LaunchId start(const std::string& title, const std::vector<std::string>& items) {
|
||||
std::string items_joined = string::join(items, PARAMETER_ITEM_CONCATENATION_TOKEN);
|
||||
auto bundle = std::make_shared<Bundle>();
|
||||
bundle->putString(PARAMETER_BUNDLE_KEY_TITLE, title);
|
||||
bundle->putString(PARAMETER_BUNDLE_KEY_ITEMS, items_joined);
|
||||
return app::start(manifest.appId, bundle);
|
||||
struct Context {
|
||||
uint32_t appInstanceId;
|
||||
// Set once in appMain() from its own argc/argv parameters, read by createWidgets() - see
|
||||
// AlertDialog.cpp's Context::argc/argv for why this is safe without a lock.
|
||||
int argc = 0;
|
||||
char** argv = nullptr;
|
||||
// The eventual appMain() return value - see AlertDialog.cpp's Context::result for why this
|
||||
// is a plain (non-atomic) field safely shared between the LVGL thread (writer, before
|
||||
// emitting APP_EVENT_CLOSE) and this dialog's own thread (reader, after waking from it).
|
||||
int32_t result = 1; // Cancelled - safety-net default if closed without selecting an item
|
||||
};
|
||||
|
||||
struct ItemContext {
|
||||
Context* ctx;
|
||||
int32_t index;
|
||||
};
|
||||
|
||||
void onItemDeleted(lv_event_t* e) {
|
||||
delete static_cast<ItemContext*>(lv_event_get_user_data(e));
|
||||
}
|
||||
|
||||
int32_t getResultIndex(const Bundle& bundle) {
|
||||
int32_t index = -1;
|
||||
bundle.optInt32(RESULT_BUNDLE_KEY_INDEX, index);
|
||||
return index;
|
||||
void onItemSelected(lv_event_t* e) {
|
||||
auto* itemCtx = static_cast<ItemContext*>(lv_event_get_user_data(e));
|
||||
LOG_I(TAG, "Selected item at index %d", (int)itemCtx->index);
|
||||
itemCtx->ctx->result = itemCtx->index;
|
||||
// Async, non-blocking - just wakes this dialog's own thread. Must NOT call
|
||||
// app_manager_stop() here: that bound-waits (thread_join) for the dialog's thread to
|
||||
// finish, which needs the LVGL lock (window_manager_remove()) - but this callback is
|
||||
// running ON the LVGL task, which would deadlock against itself. The caller reaps this
|
||||
// instance via app_manager_stop() after it receives the APP_EVENT_RESULT instead.
|
||||
AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(itemCtx->ctx->appInstanceId, &event);
|
||||
}
|
||||
|
||||
static std::string getTitleParameter(std::shared_ptr<const Bundle> bundle) {
|
||||
std::string result;
|
||||
if (bundle->optString(PARAMETER_BUNDLE_KEY_TITLE, result)) {
|
||||
return result;
|
||||
void createChoiceItem(Context* ctx, lv_obj_t* list, const std::string& title, int32_t index) {
|
||||
lv_obj_t* btn = lv_list_add_button(list, nullptr, title.c_str());
|
||||
auto* itemCtx = new ItemContext { ctx, index };
|
||||
lv_obj_add_event_cb(btn, onItemSelected, LV_EVENT_SHORT_CLICKED, itemCtx);
|
||||
lv_obj_add_event_cb(btn, onItemDeleted, LV_EVENT_DELETE, itemCtx);
|
||||
}
|
||||
|
||||
// Closes the dialog immediately with a fixed result, without ever showing a choice list -
|
||||
// mirrors the original's 0-items (error) and 1-item (auto-select) shortcuts.
|
||||
void closeWithResult(Context* ctx, int32_t result) {
|
||||
ctx->result = result;
|
||||
AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(ctx->appInstanceId, &event);
|
||||
}
|
||||
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
// argv layout: [0]=title, [1..argc)=items.
|
||||
int argc = ctx->argc;
|
||||
char** argv = ctx->argv;
|
||||
int itemCount = argc - 1;
|
||||
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
const char* title = (argv[0][0] != '\0') ? argv[0] : DEFAULT_TITLE;
|
||||
lvgl_toolbar_create(parent, title);
|
||||
|
||||
auto* list = lv_list_create(parent);
|
||||
lv_obj_set_width(list, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(list, 1);
|
||||
|
||||
if (itemCount <= 0 || argv[1][0] == '\0') {
|
||||
LOG_E(TAG, "No items provided");
|
||||
closeWithResult(ctx, -1);
|
||||
} else if (itemCount == 1) {
|
||||
LOG_W(TAG, "Auto-selecting single item");
|
||||
closeWithResult(ctx, 0);
|
||||
} else {
|
||||
return DEFAULT_TITLE;
|
||||
}
|
||||
}
|
||||
|
||||
class SelectionDialogApp final : public App {
|
||||
|
||||
static void onListItemSelectedCallback(lv_event_t* e) {
|
||||
auto app = std::static_pointer_cast<SelectionDialogApp>(getCurrentApp());
|
||||
assert(app != nullptr);
|
||||
app->onListItemSelected(e);
|
||||
}
|
||||
|
||||
void onListItemSelected(lv_event_t* e) {
|
||||
auto index = reinterpret_cast<std::size_t>(lv_event_get_user_data(e));
|
||||
LOG_I(TAG, "Selected item at index %d", (int)index);
|
||||
auto bundle = std::make_unique<Bundle>();
|
||||
bundle->putInt32(RESULT_BUNDLE_KEY_INDEX, (int32_t)index);
|
||||
setResult(Result::Ok, std::move(bundle));
|
||||
stop(manifest.appId);
|
||||
}
|
||||
|
||||
static void createChoiceItem(void* parent, const std::string& title, size_t index) {
|
||||
auto* list = static_cast<lv_obj_t*>(parent);
|
||||
lv_obj_t* btn = lv_list_add_button(list, nullptr, title.c_str());
|
||||
lv_obj_add_event_cb(btn, onListItemSelectedCallback, LV_EVENT_SHORT_CLICKED, (void*)index);
|
||||
}
|
||||
|
||||
public:
|
||||
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override {
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
std::string title = getTitleParameter(app.getParameters());
|
||||
lvgl_toolbar_create(parent, title.c_str());
|
||||
|
||||
auto* list = lv_list_create(parent);
|
||||
lv_obj_set_width(list, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(list, 1);
|
||||
|
||||
auto parameters = app.getParameters();
|
||||
check(parameters != nullptr, "Parameters missing");
|
||||
std::string items_concatenated;
|
||||
if (parameters->optString(PARAMETER_BUNDLE_KEY_ITEMS, items_concatenated)) {
|
||||
std::vector<std::string> items = string::split(items_concatenated, PARAMETER_ITEM_CONCATENATION_TOKEN);
|
||||
if (items.empty() || items.front().empty()) {
|
||||
LOG_E(TAG, "No items provided");
|
||||
setResult(Result::Error);
|
||||
stop(manifest.appId);
|
||||
} else if (items.size() == 1) {
|
||||
auto result_bundle = std::make_unique<Bundle>();
|
||||
result_bundle->putInt32(RESULT_BUNDLE_KEY_INDEX, 0);
|
||||
setResult(Result::Ok, std::move(result_bundle));
|
||||
stop(manifest.appId);
|
||||
LOG_W(TAG, "Auto-selecting single item");
|
||||
} else {
|
||||
size_t index = 0;
|
||||
for (const auto& item: items) {
|
||||
createChoiceItem(list, item, index++);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LOG_E(TAG, "No items provided");
|
||||
setResult(Result::Error);
|
||||
stop(manifest.appId);
|
||||
for (int32_t index = 0; index < itemCount; index++) {
|
||||
createChoiceItem(ctx, list, argv[1 + index], index);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
int32_t appMain(AppInstanceId appInstanceId, int argc, char* argv[]) {
|
||||
Context ctx { appInstanceId };
|
||||
ctx.argc = argc;
|
||||
ctx.argv = argv;
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
|
||||
while (true) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
if (event.type == APP_EVENT_CLOSE) {
|
||||
app_manager_finish(appInstanceId); // no-op: modal children never supersede anything
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
return ctx.result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace {
|
||||
|
||||
// Builds argv = [title, items...] for app_manager_start_for_result().
|
||||
std::vector<const char*> buildArgv(const std::string& title, const std::vector<std::string>& items) {
|
||||
std::vector<const char*> argv { title.c_str() };
|
||||
for (const auto& item: items) {
|
||||
argv.push_back(item.c_str());
|
||||
}
|
||||
return argv;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
AppInstanceId start(AppInstanceId callerAppInstanceId, const std::string& title, const std::vector<std::string>& items) {
|
||||
auto argv = buildArgv(title, items);
|
||||
AppInstanceId instanceId = 0;
|
||||
app_manager_start_for_result(manifest.id, callerAppInstanceId, static_cast<int>(argv.size()), argv.data(), &instanceId);
|
||||
return instanceId;
|
||||
}
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "SelectionDialog",
|
||||
.appName = "Selection Dialog",
|
||||
.appCategory = Category::System,
|
||||
.appFlags = AppManifest::Flags::Hidden,
|
||||
.createApp = create<SelectionDialogApp>
|
||||
.id = "SelectionDialog",
|
||||
.name = "Selection Dialog",
|
||||
.category = APP_CATEGORY_SYSTEM,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
|
||||
.flags = APP_MANIFEST_FLAG_HIDDEN,
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@@ -1,61 +1,114 @@
|
||||
#include <Tactility/app/AppRegistration.h>
|
||||
#include <Tactility/lvgl/Toolbar.h>
|
||||
#include <Tactility/service/loader/Loader.h>
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <lvgl/icons/shared.h>
|
||||
#include <lvgl/fonts.h>
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
#include <tactility/check.h>
|
||||
|
||||
#include <lvgl.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
namespace tt::app::settings {
|
||||
|
||||
static void onAppPressed(lv_event_t* e) {
|
||||
const auto* manifest = static_cast<const AppManifest*>(lv_event_get_user_data(e));
|
||||
start(manifest->appId);
|
||||
namespace {
|
||||
|
||||
uint32_t settingsInstanceId = 0;
|
||||
|
||||
void onAppPressed(lv_event_t* e) {
|
||||
// Fire-and-forget top-level navigation, same as AppList's own app-launch buttons.
|
||||
const auto* manifest = static_cast<const ::AppManifest*>(lv_event_get_user_data(e));
|
||||
uint32_t instanceId = 0;
|
||||
app_manager_start(manifest->id, &instanceId);
|
||||
}
|
||||
|
||||
static void createWidget(const std::shared_ptr<AppManifest>& manifest, void* parent) {
|
||||
check(parent);
|
||||
auto* list = static_cast<lv_obj_t*>(parent);
|
||||
const void* icon = !manifest->appIcon.empty() ? manifest->appIcon.c_str() : LVGL_ICON_SHARED_TOOLBAR;
|
||||
auto* btn = lv_list_add_button(list, icon, manifest->appName.c_str());
|
||||
void onBackPressed(lv_event_t*) {
|
||||
// The global toolbar nav callback only knows how to stop old-model apps, so this
|
||||
// new-model app overrides its own toolbar's nav action to close itself instead. Async,
|
||||
// non-blocking - see AppList.cpp's onBackPressed() for why this must not call
|
||||
// app_manager_stop() directly (would deadlock against the LVGL lock).
|
||||
AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(settingsInstanceId, &event);
|
||||
}
|
||||
|
||||
void createWidget(const ::AppManifest* manifest, lv_obj_t* list) {
|
||||
check(list);
|
||||
// The new AppManifest has no per-app icon - use a shared generic one for every entry,
|
||||
// same fallback the old model used for apps that didn't provide one.
|
||||
auto* btn = lv_list_add_button(list, LVGL_ICON_SHARED_TOOLBAR, manifest->name);
|
||||
lv_obj_t* image = lv_obj_get_child(btn, 0);
|
||||
lv_obj_set_style_text_font(image, lvgl_get_shared_icon_font(), LV_PART_MAIN);
|
||||
lv_obj_add_event_cb(btn, &onAppPressed, LV_EVENT_SHORT_CLICKED, (void*)manifest.get());
|
||||
lv_obj_add_event_cb(btn, &onAppPressed, LV_EVENT_SHORT_CLICKED, const_cast<::AppManifest*>(manifest));
|
||||
}
|
||||
|
||||
class SettingsApp final : public App {
|
||||
void collectManifest(const ::AppManifest* manifest, void* context) {
|
||||
auto* manifests = static_cast<std::vector<const ::AppManifest*>*>(context);
|
||||
manifests->push_back(manifest);
|
||||
}
|
||||
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override {
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
void createWidgets(lv_obj_t* parent, void*) {
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
|
||||
|
||||
lvgl::toolbar_create(parent, app);
|
||||
auto* toolbar = lvgl_toolbar_create(parent, "Settings");
|
||||
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, nullptr);
|
||||
|
||||
auto* list = lv_list_create(parent);
|
||||
lv_obj_set_width(list, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(list, 1);
|
||||
auto* list = lv_list_create(parent);
|
||||
lv_obj_set_width(list, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(list, 1);
|
||||
|
||||
auto manifests = getAppManifests();
|
||||
std::ranges::sort(manifests, SortAppManifestByName);
|
||||
for (const auto& manifest: manifests) {
|
||||
if (manifest->appCategory == Category::Settings) {
|
||||
createWidget(manifest, list);
|
||||
}
|
||||
std::vector<const ::AppManifest*> manifests;
|
||||
app_manager_for_each_manifest(collectManifest, &manifests);
|
||||
std::ranges::sort(manifests, [](const ::AppManifest* a, const ::AppManifest* b) {
|
||||
return strcmp(a->name, b->name) < 0;
|
||||
});
|
||||
|
||||
for (const auto* manifest: manifests) {
|
||||
if (manifest->category == APP_CATEGORY_SETTINGS && (manifest->flags & APP_MANIFEST_FLAG_HIDDEN) == 0) {
|
||||
createWidget(manifest, list);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "Settings",
|
||||
.appName = "Settings",
|
||||
.appIcon = LVGL_ICON_SHARED_SETTINGS,
|
||||
.appCategory = Category::System,
|
||||
.appFlags = AppManifest::Flags::Hidden,
|
||||
.createApp = create<SettingsApp>
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
settingsInstanceId = appInstanceId;
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, nullptr);
|
||||
|
||||
while (true) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
if (event.type == APP_EVENT_CLOSE) {
|
||||
app_manager_finish(appInstanceId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "Settings",
|
||||
.name = "Settings",
|
||||
.category = APP_CATEGORY_SYSTEM,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
|
||||
.flags = APP_MANIFEST_FLAG_HIDDEN,
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
#include <lvgl/fonts.h>
|
||||
#include <lvgl/lvgl.h>
|
||||
|
||||
#include <Tactility/app/App.h>
|
||||
#include <Tactility/app/AppManifest.h>
|
||||
#include <Tactility/app/setup/Setup.h>
|
||||
#include <Tactility/Preferences.h>
|
||||
|
||||
#include <Tactility/StringUtils.h>
|
||||
#include <Tactility/app/timezone/TimeZone.h>
|
||||
#include <Tactility/app/wifimanage/WifiManage.h>
|
||||
#include <Tactility/file/File.h>
|
||||
#include <Tactility/service/wifi/Wifi.h>
|
||||
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <tactility/log.h>
|
||||
#include <tactility/paths.h>
|
||||
|
||||
#include <lvgl/fonts.h>
|
||||
#include <lvgl/lvgl.h>
|
||||
#include <lvgl.h>
|
||||
|
||||
#include <functional>
|
||||
@@ -25,205 +32,250 @@
|
||||
|
||||
namespace tt::app::setup {
|
||||
|
||||
extern const AppManifest manifest;
|
||||
extern const ::AppManifest manifest;
|
||||
|
||||
constexpr auto* PREFERENCES_NAMESPACE = "setup";
|
||||
constexpr auto* PREFERENCES_KEY_COMPLETED = "completed";
|
||||
constexpr auto* TAG = "setup";
|
||||
|
||||
namespace {
|
||||
|
||||
bool getCompletedMarkerPath(std::string& outPath) {
|
||||
char root[128];
|
||||
if (paths_get_user_data_path(root, sizeof(root)) != ERROR_NONE) {
|
||||
return false;
|
||||
}
|
||||
outPath = std::string(root) + "/.setup_complete";
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool isCompleted() {
|
||||
Preferences preferences(PREFERENCES_NAMESPACE);
|
||||
bool completed = false;
|
||||
preferences.optBool(PREFERENCES_KEY_COMPLETED, completed);
|
||||
return completed;
|
||||
std::string path;
|
||||
if (!getCompletedMarkerPath(path)) {
|
||||
LOG_E(TAG, "Setup path not found");
|
||||
return false;
|
||||
}
|
||||
file::FileMutexGuard guard(path);
|
||||
return file::isFile(path);
|
||||
}
|
||||
|
||||
static void markCompleted() {
|
||||
Preferences preferences(PREFERENCES_NAMESPACE);
|
||||
preferences.putBool(PREFERENCES_KEY_COMPLETED, true);
|
||||
namespace {
|
||||
|
||||
void markCompleted() {
|
||||
std::string path;
|
||||
if (!getCompletedMarkerPath(path)) {
|
||||
return;
|
||||
}
|
||||
file::FileMutexGuard guard(path);
|
||||
file::writeString(path, "");
|
||||
}
|
||||
|
||||
enum class Phase {
|
||||
Welcome,
|
||||
StepIntro,
|
||||
Done
|
||||
};
|
||||
|
||||
struct StepConfiguration {
|
||||
std::string title;
|
||||
std::string description;
|
||||
std::function<void()> run;
|
||||
};
|
||||
|
||||
class SetupApp final : public App {
|
||||
|
||||
enum class Phase {
|
||||
Welcome,
|
||||
StepIntro,
|
||||
Done
|
||||
};
|
||||
struct Context {
|
||||
uint32_t appInstanceId;
|
||||
|
||||
Phase phase = Phase::Welcome;
|
||||
size_t stepIndex = 0;
|
||||
std::vector<StepConfiguration> steps;
|
||||
bool isShown = false;
|
||||
uint32_t pendingStepDialogId = 0;
|
||||
|
||||
lv_obj_t* titleLabel = nullptr;
|
||||
lv_obj_t* descriptionLabel = nullptr;
|
||||
lv_obj_t* skipButton = nullptr;
|
||||
lv_obj_t* continueButton = nullptr;
|
||||
};
|
||||
|
||||
static void onSkipClickedCallback(lv_event_t* e) {
|
||||
auto* app = (SetupApp*)lv_event_get_user_data(e);
|
||||
app->onSkipClicked();
|
||||
|
||||
void renderCurrent(Context* ctx) {
|
||||
switch (ctx->phase) {
|
||||
case Phase::Welcome: {
|
||||
lv_label_set_text(ctx->titleLabel, "Welcome");
|
||||
auto device_names = string::split(std::string(CONFIG_TT_DEVICE_NAME_SIMPLE), ",");
|
||||
lv_label_set_text_fmt(ctx->descriptionLabel, "It's time to set up your %s!", device_names.front().c_str());
|
||||
lv_obj_add_flag(ctx->skipButton, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_label_set_text(lv_obj_get_child(ctx->continueButton, 0), "Continue");
|
||||
break;
|
||||
}
|
||||
case Phase::StepIntro: {
|
||||
const auto& step = ctx->steps[ctx->stepIndex];
|
||||
lv_label_set_text(ctx->titleLabel, step.title.c_str());
|
||||
lv_label_set_text(ctx->descriptionLabel, step.description.c_str());
|
||||
lv_obj_remove_flag(ctx->skipButton, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_label_set_text(lv_obj_get_child(ctx->skipButton, 0), "Skip");
|
||||
lv_label_set_text(lv_obj_get_child(ctx->continueButton, 0), "Continue");
|
||||
break;
|
||||
}
|
||||
case Phase::Done:
|
||||
lv_label_set_text(ctx->titleLabel, "Setup Complete");
|
||||
lv_label_set_text(ctx->descriptionLabel, "You're all set.");
|
||||
lv_obj_add_flag(ctx->skipButton, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_label_set_text(lv_obj_get_child(ctx->continueButton, 0), "Finish");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void advanceTo(Context* ctx, size_t index) {
|
||||
if (index < ctx->steps.size()) {
|
||||
ctx->stepIndex = index;
|
||||
ctx->phase = Phase::StepIntro;
|
||||
} else {
|
||||
ctx->phase = Phase::Done;
|
||||
}
|
||||
|
||||
static void onContinueClickedCallback(lv_event_t* e) {
|
||||
auto* app = (SetupApp*)lv_event_get_user_data(e);
|
||||
app->onContinueClicked();
|
||||
}
|
||||
lvgl_lock();
|
||||
renderCurrent(ctx);
|
||||
lvgl_unlock();
|
||||
}
|
||||
|
||||
void renderCurrent() {
|
||||
switch (phase) {
|
||||
case Phase::Welcome: {
|
||||
lv_label_set_text(titleLabel, "Welcome");
|
||||
auto device_names = string::split(std::string(CONFIG_TT_DEVICE_NAME_SIMPLE), ",");
|
||||
lv_label_set_text_fmt(descriptionLabel, "It's time to set up your %s!", device_names.front().c_str());
|
||||
lv_obj_add_flag(skipButton, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_label_set_text(lv_obj_get_child(continueButton, 0), "Continue");
|
||||
break;
|
||||
}
|
||||
case Phase::StepIntro: {
|
||||
const auto& step = steps[stepIndex];
|
||||
lv_label_set_text(titleLabel, step.title.c_str());
|
||||
lv_label_set_text(descriptionLabel, step.description.c_str());
|
||||
lv_obj_remove_flag(skipButton, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_label_set_text(lv_obj_get_child(skipButton, 0), "Skip");
|
||||
lv_label_set_text(lv_obj_get_child(continueButton, 0), "Continue");
|
||||
break;
|
||||
}
|
||||
case Phase::Done:
|
||||
lv_label_set_text(titleLabel, "Setup Complete");
|
||||
lv_label_set_text(descriptionLabel, "You're all set.");
|
||||
lv_obj_add_flag(skipButton, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_label_set_text(lv_obj_get_child(continueButton, 0), "Finish");
|
||||
break;
|
||||
void onSkipClicked(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
if (ctx->phase == Phase::StepIntro) {
|
||||
advanceTo(ctx, ctx->stepIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
void onContinueClicked(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
switch (ctx->phase) {
|
||||
case Phase::Welcome:
|
||||
advanceTo(ctx, 0);
|
||||
break;
|
||||
case Phase::StepIntro:
|
||||
ctx->steps[ctx->stepIndex].run();
|
||||
break;
|
||||
case Phase::Done: {
|
||||
markCompleted();
|
||||
// Async, non-blocking - must NOT call app_manager_stop()/app_manager_finish()
|
||||
// directly here: this callback runs ON the LVGL task, and app-lifecycle
|
||||
// transitions must happen on this app's own thread (woken via app_event_await()).
|
||||
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
|
||||
app_event_emit(ctx->appInstanceId, &closeEvent);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void advanceTo(size_t index) {
|
||||
if (index < steps.size()) {
|
||||
stepIndex = index;
|
||||
phase = Phase::StepIntro;
|
||||
} else {
|
||||
phase = Phase::Done;
|
||||
}
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(userData);
|
||||
|
||||
// Widgets may not exist yet: onShow() runs asynchronously on the GUI task and
|
||||
// may not have (re)created them by the time onResult() advances the state.
|
||||
// onShow() calls renderCurrent() itself once the widgets are ready.
|
||||
if (isShown) {
|
||||
renderCurrent();
|
||||
}
|
||||
}
|
||||
ctx->titleLabel = lv_label_create(parent);
|
||||
lv_obj_set_width(ctx->titleLabel, LV_PCT(80));
|
||||
lv_obj_set_style_text_align(ctx->titleLabel, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_label_set_long_mode(ctx->titleLabel, LV_LABEL_LONG_WRAP);
|
||||
auto* font = lvgl_get_text_font(FONT_SIZE_LARGE);
|
||||
lv_obj_set_style_text_font(ctx->titleLabel, font, 0);
|
||||
|
||||
void onSkipClicked() {
|
||||
if (phase == Phase::StepIntro) {
|
||||
advanceTo(stepIndex + 1);
|
||||
}
|
||||
}
|
||||
ctx->descriptionLabel = lv_label_create(parent);
|
||||
lv_obj_set_width(ctx->descriptionLabel, LV_PCT(80));
|
||||
lv_obj_set_style_text_align(ctx->descriptionLabel, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_label_set_long_mode(ctx->descriptionLabel, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_align(ctx->descriptionLabel, LV_ALIGN_CENTER, 0, 0);
|
||||
|
||||
void onContinueClicked() {
|
||||
switch (phase) {
|
||||
case Phase::Welcome:
|
||||
advanceTo(0);
|
||||
break;
|
||||
case Phase::StepIntro:
|
||||
steps[stepIndex].run();
|
||||
break;
|
||||
case Phase::Done:
|
||||
markCompleted();
|
||||
stop(manifest.appId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
int title_margin = lvgl_get_text_font_height(FONT_SIZE_LARGE);
|
||||
lv_obj_align_to(ctx->titleLabel, ctx->descriptionLabel, LV_ALIGN_OUT_TOP_MID, 0, -title_margin);
|
||||
|
||||
public:
|
||||
ctx->skipButton = lv_button_create(parent);
|
||||
lv_obj_t* skip_label = lv_label_create(ctx->skipButton);
|
||||
lv_label_set_text(skip_label, "Skip");
|
||||
lv_obj_center(skip_label);
|
||||
lv_obj_align(ctx->skipButton, LV_ALIGN_BOTTOM_LEFT, 12, -12);
|
||||
lv_obj_add_event_cb(ctx->skipButton, onSkipClicked, LV_EVENT_SHORT_CLICKED, ctx);
|
||||
|
||||
void onCreate(AppContext& app) override {
|
||||
steps = {
|
||||
ctx->continueButton = lv_button_create(parent);
|
||||
lv_obj_t* continue_label = lv_label_create(ctx->continueButton);
|
||||
lv_label_set_text(continue_label, "Continue");
|
||||
lv_obj_center(continue_label);
|
||||
lv_obj_align(ctx->continueButton, LV_ALIGN_BOTTOM_RIGHT, -12, -12);
|
||||
lv_obj_add_event_cb(ctx->continueButton, onContinueClicked, LV_EVENT_SHORT_CLICKED, ctx);
|
||||
|
||||
renderCurrent(ctx);
|
||||
}
|
||||
|
||||
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
|
||||
Context ctx {};
|
||||
ctx.appInstanceId = appInstanceId;
|
||||
ctx.steps = {
|
||||
#if defined(CONFIG_TT_TOUCH_CALIBRATION_REQUIRED)
|
||||
{
|
||||
.title = "Touch Calibration",
|
||||
.description = "Let's calibrate the touch screen.",
|
||||
.run = [] { touchcalibration::start(); }
|
||||
},
|
||||
{
|
||||
.title = "Touch Calibration",
|
||||
.description = "Let's calibrate the touch screen.",
|
||||
.run = [&ctx] { ctx.pendingStepDialogId = touchcalibration::start(ctx.appInstanceId); }
|
||||
},
|
||||
#endif
|
||||
{
|
||||
.title = "Time Zone Setup",
|
||||
.description = "Let's set the time zone.",
|
||||
.run = [] { timezone::start(true); }
|
||||
},
|
||||
{
|
||||
.title = "Wi-Fi Setup",
|
||||
.description = "Let's connect to a Wi-Fi access point.",
|
||||
.run = [] {
|
||||
service::wifi::setEnabled(true);
|
||||
wifimanage::start();
|
||||
}
|
||||
{
|
||||
.title = "Time Zone Setup",
|
||||
.description = "Let's set the time zone.",
|
||||
.run = [&ctx] { ctx.pendingStepDialogId = timezone::start(ctx.appInstanceId, true); }
|
||||
},
|
||||
{
|
||||
.title = "Wi-Fi Setup",
|
||||
.description = "Let's connect to a Wi-Fi access point.",
|
||||
.run = [&ctx] {
|
||||
service::wifi::setEnabled(true);
|
||||
ctx.pendingStepDialogId = wifimanage::start(ctx.appInstanceId);
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
AppEventSubscription sub {};
|
||||
sub.app_instance_id = appInstanceId;
|
||||
app_event_subscribe(&sub);
|
||||
|
||||
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
|
||||
|
||||
bool shouldClose = false;
|
||||
while (!shouldClose) {
|
||||
AppEvent event {};
|
||||
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
|
||||
break;
|
||||
}
|
||||
switch (event.type) {
|
||||
case APP_EVENT_CLOSE:
|
||||
app_manager_finish(appInstanceId);
|
||||
shouldClose = true;
|
||||
break;
|
||||
case APP_EVENT_RESULT:
|
||||
if (event.result.launch_id == ctx.pendingStepDialogId) {
|
||||
ctx.pendingStepDialogId = 0;
|
||||
advanceTo(&ctx, ctx.stepIndex + 1);
|
||||
}
|
||||
app_manager_stop(event.result.launch_id);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void onShow(AppContext& app, lv_obj_t* parent) override {
|
||||
titleLabel = lv_label_create(parent);
|
||||
lv_obj_set_width(titleLabel, LV_PCT(80));
|
||||
lv_obj_set_style_text_align(titleLabel, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_label_set_long_mode(titleLabel, LV_LABEL_LONG_WRAP);
|
||||
auto* font = lvgl_get_text_font(FONT_SIZE_LARGE);
|
||||
lv_obj_set_style_text_font(titleLabel, font, 0);
|
||||
window_manager_remove(window);
|
||||
app_event_unsubscribe(&sub);
|
||||
|
||||
descriptionLabel = lv_label_create(parent);
|
||||
lv_obj_set_width(descriptionLabel, LV_PCT(80));
|
||||
lv_obj_set_style_text_align(descriptionLabel, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_label_set_long_mode(descriptionLabel, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_align(descriptionLabel, LV_ALIGN_CENTER, 0, 0);
|
||||
|
||||
int title_margin = lvgl_get_text_font_height(FONT_SIZE_LARGE);
|
||||
lv_obj_align_to(titleLabel, descriptionLabel, LV_ALIGN_OUT_TOP_MID, 0, -title_margin);
|
||||
|
||||
skipButton = lv_button_create(parent);
|
||||
lv_obj_t* skip_label = lv_label_create(skipButton);
|
||||
lv_label_set_text(skip_label, "Skip");
|
||||
lv_obj_center(skip_label);
|
||||
lv_obj_align(skipButton, LV_ALIGN_BOTTOM_LEFT, 12, -12);
|
||||
lv_obj_add_event_cb(skipButton, onSkipClickedCallback, LV_EVENT_SHORT_CLICKED, this);
|
||||
|
||||
continueButton = lv_button_create(parent);
|
||||
lv_obj_t* continue_label = lv_label_create(continueButton);
|
||||
lv_label_set_text(continue_label, "Continue");
|
||||
lv_obj_center(continue_label);
|
||||
lv_obj_align(continueButton, LV_ALIGN_BOTTOM_RIGHT, -12, -12);
|
||||
lv_obj_add_event_cb(continueButton, onContinueClickedCallback, LV_EVENT_SHORT_CLICKED, this);
|
||||
|
||||
isShown = true;
|
||||
renderCurrent();
|
||||
}
|
||||
|
||||
void onHide(AppContext& app) override {
|
||||
isShown = false;
|
||||
}
|
||||
|
||||
void onResult(AppContext& app, LaunchId launchId, Result result, std::unique_ptr<Bundle> bundle) override {
|
||||
lvgl_lock();
|
||||
advanceTo(stepIndex + 1);
|
||||
lvgl_unlock();
|
||||
}
|
||||
};
|
||||
|
||||
extern const AppManifest manifest = {
|
||||
.appId = "Setup",
|
||||
.appName = "Setup",
|
||||
.appCategory = Category::System,
|
||||
.appFlags = AppManifest::Flags::Hidden | AppManifest::Flags::HideStatusBar,
|
||||
.createApp = create<SetupApp>
|
||||
};
|
||||
|
||||
LaunchId start() {
|
||||
return app::start(manifest.appId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void start() {
|
||||
uint32_t instanceId = 0;
|
||||
app_manager_start(manifest.id, &instanceId);
|
||||
}
|
||||
|
||||
extern const ::AppManifest manifest = {
|
||||
.id = "Setup",
|
||||
.name = "Setup",
|
||||
.category = APP_CATEGORY_SYSTEM,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
|
||||
.flags = APP_MANIFEST_FLAG_HIDDEN,
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user