Преглед изворни кода

Merge pull request #1 from rdefeo/new-ui

New UI
rdefeo пре 1 година
родитељ
комит
ffbe77d738

+ 1 - 1
actions/action.c

@@ -4,7 +4,7 @@
 #include "action_i.h"
 
 void action_tx(void* context, Item* item, FuriString* error) {
-    FURI_LOG_I(TAG, "action_run: %s : %s", furi_string_get_cstr(item->name), item->ext);
+    // FURI_LOG_I(TAG, "action_run: %s : %s", furi_string_get_cstr(item->name), item->ext);
 
     if(!strcmp(item->ext, ".sub")) {
         action_subghz_tx(context, item->path, error);

+ 4 - 4
actions/action_i.h

@@ -5,7 +5,7 @@
 
 #define ACTION_SET_ERROR(_msg_fmt, ...) furi_string_printf(error, _msg_fmt, ##__VA_ARGS__)
 
-void action_subghz_tx(void* context, FuriString* action_path, FuriString* error);
-void action_rfid_tx(void* context, FuriString* action_path, FuriString* error);
-void action_ir_tx(void* context, FuriString* action_path, FuriString* error);
-void action_qpl_tx(void* context, FuriString* action_path, FuriString* error);
+void action_subghz_tx(void* context, const FuriString* action_path, FuriString* error);
+void action_rfid_tx(void* context, const FuriString* action_path, FuriString* error);
+void action_ir_tx(void* context, const FuriString* action_path, FuriString* error);
+void action_qpl_tx(void* context, const FuriString* action_path, FuriString* error);

+ 1 - 1
actions/action_ir.c

@@ -29,7 +29,7 @@ InfraredSignal* infrared_signal_alloc() {
     return signal;
 }
 
-void action_ir_tx(void* context, FuriString* action_path, FuriString* error) {
+void action_ir_tx(void* context, const FuriString* action_path, FuriString* error) {
     UNUSED(action_path);
     UNUSED(error);
     UNUSED(context);

+ 17 - 9
actions/action_qpl.c

@@ -11,22 +11,28 @@
 #include "quac.h"
 
 /** Open the Playlist file and then transmit each action
+ * 
  * Each line of the playlist file is one of:
  *   <file_path>
  *      Full SD card path, or relative path to action to be transmitted. Must be
  *      one of the supported filetypes (.sub, .rfid, [.ir coming soon])
- *   pause <ms> - NOT IMPLEMENTED
+ * 
+ *      If an .rfid file has a space followed by a number, that will be the
+ *      duration for that RFID transmission. All other .rfid files will use
+ *      the value specified in the Settings
+ * 
+ *   pause <ms>
  *      Pauses the playback for 'ms' milliseconds.
  * 
  * Blank lines, and comments (start with '#') are ignored. Whitespace is trimmed.
  * 
- * Not yet Implemented:
- * - For RFID files, if they have a space followed by a number after their name,
- *   that number will be the duration of that RFID tx
 */
-void action_qpl_tx(void* context, FuriString* action_path, FuriString* error) {
+void action_qpl_tx(void* context, const FuriString* action_path, FuriString* error) {
     App* app = context;
 
+    // Save the current RFID Duration, in case it is changed during playback
+    uint32_t orig_rfid_duration = app->settings.rfid_duration;
+
     FuriString* buffer;
     buffer = furi_string_alloc();
 
@@ -34,7 +40,7 @@ void action_qpl_tx(void* context, FuriString* action_path, FuriString* error) {
     if(file_stream_open(file, furi_string_get_cstr(action_path), FSAM_READ, FSOM_OPEN_EXISTING)) {
         while(stream_read_line(file, buffer)) {
             furi_string_trim(buffer); // remove '\n\r' line endings, cleanup spaces
-            FURI_LOG_I(TAG, "line: %s", furi_string_get_cstr(buffer));
+            // FURI_LOG_I(TAG, "line: %s", furi_string_get_cstr(buffer));
 
             // Skip blank lines
             if(furi_string_size(buffer) == 0) {
@@ -100,7 +106,7 @@ void action_qpl_tx(void* context, FuriString* action_path, FuriString* error) {
                     // FURI_LOG_I(TAG, "RFID file with duration");
                     if(sscanf(furi_string_get_cstr(buffer), "%lu", &rfid_duration) == 1) {
                         FURI_LOG_I(TAG, "RFID duration = %lu", rfid_duration);
-                        // TODO: Need to get the duration to the action_rfid_tx command...
+                        app->settings.rfid_duration = rfid_duration;
                     }
                 }
 
@@ -128,10 +134,12 @@ void action_qpl_tx(void* context, FuriString* action_path, FuriString* error) {
             path_extract_extension(buffer, ext, MAX_EXT_LEN);
             if(!strcmp(ext, ".sub")) {
                 action_subghz_tx(context, buffer, error);
-            } else if(!strcmp(ext, ".ir")) {
-                action_ir_tx(context, buffer, error);
             } else if(!strcmp(ext, ".rfid")) {
                 action_rfid_tx(context, buffer, error);
+                // Reset our default duration back - in case it was changed during playback
+                app->settings.rfid_duration = orig_rfid_duration;
+            } else if(!strcmp(ext, ".ir")) {
+                action_ir_tx(context, buffer, error);
             } else if(!strcmp(ext, ".qpl")) {
                 ACTION_SET_ERROR("Playlist: Can't call playlist from playlist");
             } else {

+ 15 - 24
actions/action_rfid.c

@@ -13,40 +13,34 @@
 #include "quac.h"
 
 // lifted from flipperzero-firmware/applications/main/lfrfid/lfrfid_cli.c
-void action_rfid_tx(void* context, FuriString* action_path, FuriString* error) {
+void action_rfid_tx(void* context, const FuriString* action_path, FuriString* error) {
     UNUSED(error);
 
     App* app = context;
-    FuriString* file_name = action_path;
+    const FuriString* file_name = action_path;
 
     FlipperFormat* fff_data_file = flipper_format_file_alloc(app->storage);
     FuriString* temp_str;
     temp_str = furi_string_alloc();
     uint32_t temp_data32;
 
-    FuriString* protocol_name;
-    FuriString* data_text;
-    protocol_name = furi_string_alloc();
-    data_text = furi_string_alloc();
-
     ProtocolDict* dict = protocol_dict_alloc(lfrfid_protocols, LFRFIDProtocolMax);
     ProtocolId protocol;
     size_t data_size = protocol_dict_get_max_data_size(dict);
     uint8_t* data = malloc(data_size);
 
-    FURI_LOG_I(TAG, "Max dict data size is %d", data_size);
+    // FURI_LOG_I(TAG, "Max dict data size is %d", data_size);
     bool successful_read = false;
     do {
         if(!flipper_format_file_open_existing(fff_data_file, furi_string_get_cstr(file_name))) {
             ACTION_SET_ERROR("RFID: Error opening %s", furi_string_get_cstr(file_name));
             break;
         }
-        FURI_LOG_I(TAG, "Opened file");
         if(!flipper_format_read_header(fff_data_file, temp_str, &temp_data32)) {
             ACTION_SET_ERROR("RFID: Missing or incorrect header");
             break;
         }
-        FURI_LOG_I(TAG, "Read file headers");
+        // FURI_LOG_I(TAG, "Read file headers");
         // TODO: add better header checks here...
         if(!strcmp(furi_string_get_cstr(temp_str), "Flipper RFID key")) {
         } else {
@@ -55,21 +49,21 @@ void action_rfid_tx(void* context, FuriString* action_path, FuriString* error) {
         }
 
         // read and check the protocol field
-        if(!flipper_format_read_string(fff_data_file, "Key type", protocol_name)) {
+        if(!flipper_format_read_string(fff_data_file, "Key type", temp_str)) {
             ACTION_SET_ERROR("RFID: Error reading protocol");
             break;
         }
-        protocol = protocol_dict_get_protocol_by_name(dict, furi_string_get_cstr(protocol_name));
+        protocol = protocol_dict_get_protocol_by_name(dict, furi_string_get_cstr(temp_str));
         if(protocol == PROTOCOL_NO) {
-            ACTION_SET_ERROR("RFID: Unknown protocol: %s", furi_string_get_cstr(protocol_name));
+            ACTION_SET_ERROR("RFID: Unknown protocol: %s", furi_string_get_cstr(temp_str));
             break;
         }
 
         // read and check data field
         size_t required_size = protocol_dict_get_data_size(dict, protocol);
-        FURI_LOG_I(TAG, "Protocol req data size is %d", required_size);
+        // FURI_LOG_I(TAG, "Protocol req data size is %d", required_size);
         if(!flipper_format_read_hex(fff_data_file, "Data", data, required_size)) {
-            FURI_LOG_E(TAG, "Error reading data");
+            FURI_LOG_E(TAG, "RFID: Error reading data");
             ACTION_SET_ERROR("RFID: Error reading data");
             break;
         }
@@ -86,7 +80,7 @@ void action_rfid_tx(void* context, FuriString* action_path, FuriString* error) {
 
         protocol_dict_set_data(dict, protocol, data, data_size);
         successful_read = true;
-        FURI_LOG_I(TAG, "protocol dict setup complete!");
+        // FURI_LOG_I(TAG, "protocol dict setup complete!");
     } while(false);
 
     if(successful_read) {
@@ -95,14 +89,15 @@ void action_rfid_tx(void* context, FuriString* action_path, FuriString* error) {
         lfrfid_worker_start_thread(worker);
         lfrfid_worker_emulate_start(worker, protocol);
 
-        FURI_LOG_I(TAG, "Emulating RFID...");
-        int16_t time_ms = 3000;
-        int16_t interval_ms = 200;
+        int16_t time_ms = app->settings.rfid_duration;
+        FURI_LOG_I(
+            TAG, "RFID: Emulating RFID (%s) for %d ms", furi_string_get_cstr(file_name), time_ms);
+        int16_t interval_ms = 100;
         while(time_ms > 0) {
             furi_delay_ms(interval_ms);
             time_ms -= interval_ms;
         }
-        FURI_LOG_I(TAG, "Emulation stopped");
+        FURI_LOG_I(TAG, "RFID: Emulation stopped");
 
         lfrfid_worker_stop(worker);
         lfrfid_worker_stop_thread(worker);
@@ -110,11 +105,7 @@ void action_rfid_tx(void* context, FuriString* action_path, FuriString* error) {
     }
 
     furi_string_free(temp_str);
-    furi_string_free(protocol_name);
-    furi_string_free(data_text);
     free(data);
-
     protocol_dict_free(dict);
-
     flipper_format_free(fff_data_file);
 }

+ 7 - 7
actions/action_subghz.c

@@ -31,9 +31,9 @@ static FuriHalSubGhzPreset action_subghz_get_preset_name(const char* preset_name
 }
 
 // Lifted from flipperzero-firmware/applications/main/subghz/subghz_cli.c
-void action_subghz_tx(void* context, FuriString* action_path, FuriString* error) {
+void action_subghz_tx(void* context, const FuriString* action_path, FuriString* error) {
     App* app = context;
-    FuriString* file_name = action_path;
+    const char* file_name = furi_string_get_cstr(action_path);
     uint32_t repeat = 1; //
     // uint32_t device_ind = 0; // 0 - CC1101_INT, 1 - CC1101_EXT
 
@@ -76,9 +76,9 @@ void action_subghz_tx(void* context, FuriString* action_path, FuriString* error)
             // device_ind = 0;
         }
 
-        if(!flipper_format_file_open_existing(fff_data_file, furi_string_get_cstr(file_name))) {
-            FURI_LOG_E(TAG, "Error opening %s", furi_string_get_cstr(file_name));
-            ACTION_SET_ERROR("SUBGHZ: Error opening %s", furi_string_get_cstr(file_name));
+        if(!flipper_format_file_open_existing(fff_data_file, file_name)) {
+            FURI_LOG_E(TAG, "Error opening %s", file_name);
+            ACTION_SET_ERROR("SUBGHZ: Error opening %s", file_name);
             break;
         }
 
@@ -169,7 +169,7 @@ void action_subghz_tx(void* context, FuriString* action_path, FuriString* error)
         if(!strcmp(furi_string_get_cstr(temp_str), "RAW")) {
             FURI_LOG_I(TAG, "Protocol = RAW");
             subghz_protocol_raw_gen_fff_data(
-                fff_data_raw, furi_string_get_cstr(file_name), subghz_devices_get_name(device));
+                fff_data_raw, file_name, subghz_devices_get_name(device));
             transmitter =
                 subghz_transmitter_alloc_init(environment, furi_string_get_cstr(temp_str));
             if(transmitter == NULL) {
@@ -219,7 +219,7 @@ void action_subghz_tx(void* context, FuriString* action_path, FuriString* error)
         FURI_LOG_I(
             TAG,
             "Listening at %s. Frequency=%lu, Protocol=%s",
-            furi_string_get_cstr(file_name),
+            file_name,
             frequency,
             furi_string_get_cstr(temp_str));
         do {

+ 0 - 16
flipper.h

@@ -1,16 +0,0 @@
-#pragma once
-
-#include <furi.h>
-
-#include <gui/gui.h>
-#include <gui/view_dispatcher.h>
-#include <gui/scene_manager.h>
-#include <gui/modules/menu.h>
-#include <gui/modules/submenu.h>
-#include <gui/modules/button_menu.h>
-#include <gui/modules/dialog_ex.h>
-
-#include <storage/storage.h>
-#include <notification/notification_messages.h>
-
-#define TAG "Quac" // log statement id

BIN
images/ArrowDown_8x4.png


BIN
images/ArrowUp_8x4.png


BIN
images/Directory_10px.png


BIN
images/IR_10px.png


BIN
images/NFC_10px.png


BIN
images/Playlist_10px.png


BIN
images/RFID_10px.png


BIN
images/Settings_10px.png


BIN
images/SubGHz_10px.png


+ 32 - 23
item.c

@@ -10,25 +10,26 @@
 
 ARRAY_DEF(FileArray, FuriString*, FURI_STRING_OPLIST);
 
-ItemsView* item_get_items_view_from_path(void* context, FuriString* input_path) {
+ItemsView* item_get_items_view_from_path(void* context, const FuriString* input_path) {
     App* app = context;
 
     // Handle the app start condition
+    FuriString* in_path;
     if(input_path == NULL) {
-        input_path = furi_string_alloc_set_str(QUAC_DATA_PATH);
+        in_path = furi_string_alloc_set_str(QUAC_DATA_PATH);
+    } else {
+        in_path = furi_string_alloc_set(input_path);
     }
-    const char* cpath = furi_string_get_cstr(input_path);
+    const char* cpath = furi_string_get_cstr(in_path);
 
-    FURI_LOG_I(TAG, "Getting items from: %s", cpath);
+    FURI_LOG_I(TAG, "Reading items from path: %s", cpath);
     ItemsView* iview = malloc(sizeof(ItemsView));
-    iview->path = furi_string_alloc_set(input_path);
+    iview->path = furi_string_alloc_set(in_path);
 
     iview->name = furi_string_alloc();
     if(app->depth == 0) {
-        FURI_LOG_I(TAG, "Depth is ZERO!");
         furi_string_set_str(iview->name, QUAC_NAME);
     } else {
-        FURI_LOG_I(TAG, "Depth is %d", app->depth);
         path_extract_basename(cpath, iview->name);
         item_prettify_name(iview->name);
     }
@@ -43,17 +44,17 @@ ItemsView* item_get_items_view_from_path(void* context, FuriString* input_path)
     FuriString* filename_tmp;
     filename_tmp = furi_string_alloc();
 
-    // FURI_LOG_I(TAG, "About to walk the dir");
+    // Walk the directory and store all file names in sorted order
     if(dir_walk_open(dir_walk, cpath)) {
         while(dir_walk_read(dir_walk, path, NULL) == DirWalkOK) {
-            FURI_LOG_I(TAG, "> dir_walk: %s", furi_string_get_cstr(path));
+            // FURI_LOG_I(TAG, "> dir_walk: %s", furi_string_get_cstr(path));
             const char* cpath = furi_string_get_cstr(path);
 
             // Skip "hidden" files
             path_extract_filename(path, filename_tmp, false);
             char first_char = furi_string_get_char(filename_tmp, 0);
             if(first_char == '.') {
-                FURI_LOG_I(TAG, ">> skipping hidden file: %s", furi_string_get_cstr(filename_tmp));
+                // FURI_LOG_I(TAG, ">> skipping hidden file: %s", furi_string_get_cstr(filename_tmp));
                 continue;
             }
 
@@ -78,14 +79,8 @@ ItemsView* item_get_items_view_from_path(void* context, FuriString* input_path)
     furi_string_free(filename_tmp);
     furi_string_free(path);
 
-    // DEBUG: Now print our array in original order
+    // Generate our Item list
     FileArray_it_t iter;
-    for(FileArray_it(iter, flist); !FileArray_end_p(iter); FileArray_next(iter)) {
-        const char* f = furi_string_get_cstr(*FileArray_cref(iter));
-        FURI_LOG_I(TAG, "Found: %s", f);
-    }
-
-    FURI_LOG_I(TAG, "Creating our ItemsArray");
     ItemArray_init(iview->items);
     for(FileArray_it(iter, flist); !FileArray_end_p(iter); FileArray_next(iter)) {
         path = *FileArray_ref(iter);
@@ -93,21 +88,22 @@ ItemsView* item_get_items_view_from_path(void* context, FuriString* input_path)
 
         Item* item = ItemArray_push_new(iview->items);
 
-        // Action files have extensions, so item->ext starts with '.' - ehhhh
+        // Action files have extensions, so item->ext starts with '.'
         item->ext[0] = 0;
         path_extract_extension(path, item->ext, MAX_EXT_LEN);
-        item->type = (item->ext[0] == '.') ? Item_Action : Item_Group;
+        item->type = item_get_item_type_from_extension(item->ext);
 
         item->name = furi_string_alloc();
         path_extract_filename_no_ext(found_path, item->name);
-        FURI_LOG_I(TAG, "Basename: %s", furi_string_get_cstr(item->name));
+        // FURI_LOG_I(TAG, "Basename: %s", furi_string_get_cstr(item->name));
         item_prettify_name(item->name);
 
         item->path = furi_string_alloc();
         furi_string_set(item->path, path);
-        FURI_LOG_I(TAG, "Path: %s", furi_string_get_cstr(item->path));
+        // FURI_LOG_I(TAG, "Path: %s", furi_string_get_cstr(item->path));
     }
 
+    furi_string_free(in_path);
     FileArray_clear(flist);
     dir_walk_free(dir_walk);
 
@@ -115,7 +111,6 @@ ItemsView* item_get_items_view_from_path(void* context, FuriString* input_path)
 }
 
 void item_items_view_free(ItemsView* items_view) {
-    FURI_LOG_I(TAG, "item_items_view_free - begin");
     furi_string_free(items_view->name);
     furi_string_free(items_view->path);
     ItemArray_it_t iter;
@@ -125,7 +120,6 @@ void item_items_view_free(ItemsView* items_view) {
     }
     ItemArray_clear(items_view->items);
     free(items_view);
-    FURI_LOG_I(TAG, "item_items_view_free - end");
 }
 
 void item_prettify_name(FuriString* name) {
@@ -142,4 +136,19 @@ void item_prettify_name(FuriString* name) {
     }
     furi_string_replace_str(name, "_", " ", 0);
     // FURI_LOG_I(TAG, "... %s", furi_string_get_cstr(name));
+}
+
+ItemType item_get_item_type_from_extension(const char* ext) {
+    ItemType type = Item_Group;
+
+    if(!strcmp(ext, ".sub")) {
+        type = Item_SubGhz;
+    } else if(!strcmp(ext, ".rfid")) {
+        type = Item_RFID;
+    } else if(!strcmp(ext, ".ir")) {
+        type = Item_IR;
+    } else if(!strcmp(ext, ".qpl")) {
+        type = Item_Playlist;
+    }
+    return type;
 }

+ 16 - 3
item.h

@@ -9,7 +9,15 @@
  * on-screen as well as to perform that action.
 */
 
-typedef enum { Item_Action, Item_Group } ItemType;
+typedef enum {
+    Item_SubGhz,
+    Item_RFID,
+    Item_IR,
+    Item_Playlist,
+    Item_Group,
+    Item_Settings,
+    Item_count
+} ItemType;
 
 typedef struct Item {
     ItemType type;
@@ -34,7 +42,7 @@ typedef struct ItemsView {
  * @param   path    FuriString*
  * @return  ItemsView*
 */
-ItemsView* item_get_items_view_from_path(void* context, FuriString* path);
+ItemsView* item_get_items_view_from_path(void* context, const FuriString* path);
 
 /** Free ItemsView
  * @param   items_view
@@ -45,4 +53,9 @@ void item_items_view_free(ItemsView* items_view);
  * as well as replace all '_' with ' '.
  * @param   name    FuriString*
 */
-void item_prettify_name(FuriString* name);
+void item_prettify_name(FuriString* name);
+
+/** Return the ItemType enum for the given extension
+ * @param   ext     File extension
+*/
+ItemType item_get_item_type_from_extension(const char* ext);

+ 30 - 10
quac.c

@@ -3,8 +3,8 @@
 #include <gui/gui.h>
 #include <gui/view_dispatcher.h>
 #include <gui/scene_manager.h>
-#include <gui/modules/button_menu.h>
 #include <gui/modules/dialog_ex.h>
+#include <gui/modules/variable_item_list.h>
 
 #include <storage/storage.h>
 #include <notification/notification_messages.h>
@@ -14,6 +14,7 @@
 #include "scenes/scene_items.h"
 
 #include "quac.h"
+#include "quac_settings.h"
 
 /* generated by fbt from .png files in images folder */
 #include <quac_icons.h>
@@ -28,12 +29,18 @@ App* app_alloc() {
     view_dispatcher_set_navigation_event_callback(app->view_dispatcher, app_back_event_callback);
 
     // Create our UI elements
-    app->btn_menu = button_menu_alloc();
+    // Main interface
+    app->action_menu = action_menu_alloc();
     view_dispatcher_add_view(
-        app->view_dispatcher, SR_ButtonMenu, button_menu_get_view(app->btn_menu));
+        app->view_dispatcher, Q_ActionMenu, action_menu_get_view(app->action_menu));
+
+    // App settings
+    app->vil_settings = variable_item_list_alloc();
+    view_dispatcher_add_view(
+        app->view_dispatcher, Q_Settings, variable_item_list_get_view(app->vil_settings));
 
     app->dialog = dialog_ex_alloc();
-    view_dispatcher_add_view(app->view_dispatcher, SR_Dialog, dialog_ex_get_view(app->dialog));
+    view_dispatcher_add_view(app->view_dispatcher, Q_Dialog, dialog_ex_get_view(app->dialog));
 
     // Storage
     app->storage = furi_record_open(RECORD_STORAGE);
@@ -41,12 +48,10 @@ App* app_alloc() {
     // Notifications - for LED light access
     app->notifications = furi_record_open(RECORD_NOTIFICATION);
 
-    // initialize device items list
+    // data member initialize
     app->depth = 0;
     app->selected_item = -1;
 
-    app->items_view = item_get_items_view_from_path(app, NULL);
-
     return app;
 }
 
@@ -55,9 +60,14 @@ void app_free(App* app) {
 
     item_items_view_free(app->items_view);
 
-    view_dispatcher_remove_view(app->view_dispatcher, SR_ButtonMenu);
+    view_dispatcher_remove_view(app->view_dispatcher, Q_ActionMenu);
+    view_dispatcher_remove_view(app->view_dispatcher, Q_Settings);
+    view_dispatcher_remove_view(app->view_dispatcher, Q_Dialog);
+
+    action_menu_free(app->action_menu);
+    variable_item_list_free(app->vil_settings);
+    dialog_ex_free(app->dialog);
 
-    button_menu_free(app->btn_menu);
     scene_manager_free(app->scene_manager);
     view_dispatcher_free(app->view_dispatcher);
 
@@ -72,14 +82,24 @@ int32_t quac_app(void* p) {
     UNUSED(p);
     FURI_LOG_I(TAG, "QUAC! QUAC!");
 
+    size_t free_start = memmgr_get_free_heap();
+
     App* app = app_alloc();
+    quac_load_settings(app);
+
+    // Read items at our root
+    app->items_view = item_get_items_view_from_path(app, NULL);
 
     Gui* gui = furi_record_open(RECORD_GUI);
     view_dispatcher_attach_to_gui(app->view_dispatcher, gui, ViewDispatcherTypeFullscreen);
-    scene_manager_next_scene(app->scene_manager, SR_Scene_Items);
+    scene_manager_next_scene(app->scene_manager, Q_Scene_Items);
     view_dispatcher_run(app->view_dispatcher);
 
     furi_record_close(RECORD_GUI);
     app_free(app);
+
+    size_t free_end = memmgr_get_free_heap();
+    FURI_LOG_W(TAG, "Heap: Start = %d, End = %d", free_start, free_end);
+
     return 0;
 }

+ 18 - 3
quac.h

@@ -2,11 +2,14 @@
 
 #include <gui/scene_manager.h>
 #include <gui/view_dispatcher.h>
-#include <gui/modules/button_menu.h>
 #include <gui/modules/dialog_ex.h>
+#include <gui/modules/variable_item_list.h>
+
 #include <storage/storage.h>
 #include <notification/notification_messages.h>
 
+#include "views/action_menu.h"
+
 #include "item.h"
 
 #define QUAC_NAME "Quac!"
@@ -17,18 +20,30 @@
 // Full path to actions
 #define QUAC_DATA_PATH EXT_PATH(QUAC_PATH)
 
+typedef enum { QUAC_APP_PORTRAIT, QUAC_APP_LANDSCAPE } QuacAppLayout;
+
 typedef struct App {
     SceneManager* scene_manager;
     ViewDispatcher* view_dispatcher;
-    ButtonMenu* btn_menu;
+
+    ActionMenu* action_menu;
+    VariableItemList* vil_settings;
     DialogEx* dialog;
 
     Storage* storage;
     NotificationApp* notifications;
 
-    int depth;
     ItemsView* items_view;
+    int depth;
     int selected_item;
+
+    struct {
+        QuacAppLayout layout; // Defaults to Portrait
+        bool show_icons; // Defaults to True
+        bool show_headers; // Defaults to True
+        uint32_t rfid_duration; // Defaults to 2500 ms
+    } settings;
+
 } App;
 
 App* app_alloc();

+ 137 - 0
quac_settings.c

@@ -0,0 +1,137 @@
+#include "quac_settings.h"
+
+#include <flipper_format/flipper_format.h>
+
+// Quac Settings File Info
+// "/ext/apps_data/quac/.quac.conf"
+#define QUAC_SETTINGS_FILENAME QUAC_DATA_PATH "/.quac.conf"
+#define QUAC_SETTINGS_FILE_TYPE "Quac Settings File"
+#define QUAC_SETTINGS_FILE_VERSION 1
+
+void quac_set_default_settings(App* app) {
+    app->settings.rfid_duration = 2500;
+    app->settings.layout = QUAC_APP_LANDSCAPE;
+    app->settings.show_icons = true;
+    app->settings.show_headers = true;
+}
+
+void quac_load_settings(App* app) {
+    FlipperFormat* fff_settings = flipper_format_file_alloc(app->storage);
+    FuriString* temp_str;
+    temp_str = furi_string_alloc();
+    uint32_t temp_data32 = 0;
+
+    FURI_LOG_I(TAG, "SETTINGS: Reading: %s", QUAC_SETTINGS_FILENAME);
+    bool successful = false;
+    do {
+        if(!flipper_format_file_open_existing(fff_settings, QUAC_SETTINGS_FILENAME)) {
+            FURI_LOG_I(TAG, "SETTINGS: File not found, loading defaults");
+            break;
+        }
+
+        if(!flipper_format_read_header(fff_settings, temp_str, &temp_data32)) {
+            FURI_LOG_E(TAG, "SETTINGS: Missing or incorrect header");
+            break;
+        }
+
+        if((!strcmp(furi_string_get_cstr(temp_str), QUAC_SETTINGS_FILE_TYPE)) &&
+           (temp_data32 == QUAC_SETTINGS_FILE_VERSION)) {
+        } else {
+            FURI_LOG_E(TAG, "SETTINGS: Type or version mismatch");
+            break;
+        }
+
+        // Now read actual values we care about
+        if(!flipper_format_read_string(fff_settings, "Layout", temp_str)) {
+            FURI_LOG_E(TAG, "SETTINGS: Missing Layout");
+            break;
+        }
+        if(!strcmp(furi_string_get_cstr(temp_str), "Landscape")) {
+            app->settings.layout = QUAC_APP_LANDSCAPE;
+        } else if(!strcmp(furi_string_get_cstr(temp_str), "Portrait")) {
+            app->settings.layout = QUAC_APP_PORTRAIT;
+        } else {
+            FURI_LOG_E(TAG, "SETTINGS: Invalid Layout");
+            break;
+        }
+
+        if(!flipper_format_read_uint32(fff_settings, "Show Icons", &temp_data32, 1)) {
+            FURI_LOG_E(TAG, "SETTINGS: Missing 'Show Icons'");
+            break;
+        }
+        app->settings.show_icons = (temp_data32 == 0) ? false : true;
+
+        if(!flipper_format_read_uint32(fff_settings, "Show Headers", &temp_data32, 1)) {
+            FURI_LOG_E(TAG, "SETTINGS: Missing 'Show Headers'");
+            break;
+        }
+        app->settings.show_headers = (temp_data32 == 0) ? false : true;
+
+        if(!flipper_format_read_uint32(fff_settings, "RFID Duration", &temp_data32, 1)) {
+            FURI_LOG_E(TAG, "SETTINGS: Missing 'RFID Duration'");
+            break;
+        }
+        app->settings.rfid_duration = temp_data32;
+
+        successful = true;
+    } while(false);
+
+    if(!successful) {
+        quac_set_default_settings(app);
+    }
+
+    furi_string_free(temp_str);
+    flipper_format_free(fff_settings);
+}
+
+void quac_save_settings(App* app) {
+    FlipperFormat* fff_settings = flipper_format_file_alloc(app->storage);
+    uint32_t temp_data32;
+
+    FURI_LOG_I(TAG, "SETTINGS: Saving");
+    bool successful = false;
+    do {
+        if(!flipper_format_file_open_always(fff_settings, QUAC_SETTINGS_FILENAME)) {
+            FURI_LOG_E(TAG, "SETTINGS: Unable to open file for save!!");
+            break;
+        }
+
+        if(!flipper_format_write_header_cstr(
+               fff_settings, QUAC_SETTINGS_FILE_TYPE, QUAC_SETTINGS_FILE_VERSION)) {
+            FURI_LOG_E(TAG, "SETTINGS: Failed writing file type and version");
+            break;
+        }
+        // layout, icons, headers, duration
+        if(!flipper_format_write_string_cstr(
+               fff_settings,
+               "Layout",
+               app->settings.layout == QUAC_APP_LANDSCAPE ? "Landscape" : "Portrait")) {
+            FURI_LOG_E(TAG, "SETTINGS: Failed to write Layout");
+            break;
+        }
+
+        temp_data32 = app->settings.show_icons ? 1 : 0;
+        if(!flipper_format_write_uint32(fff_settings, "Show Icons", &temp_data32, 1)) {
+            FURI_LOG_E(TAG, "SETTINGS: Failed to write 'Show Icons'");
+            break;
+        }
+        temp_data32 = app->settings.show_headers ? 1 : 0;
+        if(!flipper_format_write_uint32(fff_settings, "Show Headers", &temp_data32, 1)) {
+            FURI_LOG_E(TAG, "SETTINGS: Failed to write 'Show Headers'");
+            break;
+        }
+        if(!flipper_format_write_uint32(
+               fff_settings, "RFID Duration", &app->settings.rfid_duration, 1)) {
+            FURI_LOG_E(TAG, "SETTINGS: Failed to write 'RFID Duration'");
+            break;
+        }
+        successful = true;
+    } while(false);
+
+    if(!successful) {
+        FURI_LOG_E(TAG, "SETTINGS: Failed to save settings!!");
+    }
+
+    flipper_format_file_close(fff_settings);
+    flipper_format_free(fff_settings);
+}

+ 14 - 0
quac_settings.h

@@ -0,0 +1,14 @@
+#pragma once
+
+#include "quac.h"
+
+/** Set the default Settings for Quac */
+void quac_set_default_settings(App* app);
+
+/** Load the Settings from the .quac.conf file. If not found,
+ * then load the defaults.
+*/
+void quac_load_settings(App* app);
+
+/** Save the current settings to the .quac.conf file */
+void quac_save_settings(App* app);

+ 86 - 57
scenes/scene_items.c

@@ -2,7 +2,6 @@
 
 #include <gui/view_dispatcher.h>
 #include <gui/scene_manager.h>
-#include <gui/modules/button_menu.h>
 #include <gui/modules/dialog_ex.h>
 
 #include <notification/notification_messages.h>
@@ -11,8 +10,19 @@
 #include "scenes.h"
 #include "scene_items.h"
 #include "../actions/action.h"
+#include "../views/action_menu.h"
+
 #include <lib/toolbox/path.h>
 
+static const ActionMenuItemType ItemToMenuItem[] = {
+    [Item_SubGhz] = ActionMenuItemTypeSubGHz,
+    [Item_RFID] = ActionMenuItemTypeRFID,
+    [Item_IR] = ActionMenuItemTypeIR,
+    [Item_Playlist] = ActionMenuItemTypePlaylist,
+    [Item_Group] = ActionMenuItemTypeGroup,
+    [Item_Settings] = ActionMenuItemTypeSettings,
+};
+
 void scene_items_item_callback(void* context, int32_t index, InputType type) {
     App* app = context;
 
@@ -27,85 +37,106 @@ void scene_items_item_callback(void* context, int32_t index, InputType type) {
 // For each scene, implement handler callbacks
 void scene_items_on_enter(void* context) {
     App* app = context;
-    ButtonMenu* menu = app->btn_menu;
-    button_menu_reset(menu);
-    DialogEx* dialog = app->dialog;
-    dialog_ex_reset(dialog);
+
+    ActionMenu* menu = app->action_menu;
+    action_menu_reset(menu);
+    if(app->settings.layout == QUAC_APP_LANDSCAPE)
+        action_menu_set_layout(menu, ActionMenuLayoutLandscape);
+    else
+        action_menu_set_layout(menu, ActionMenuLayoutPortrait);
+    action_menu_set_show_icons(menu, app->settings.show_icons);
+    action_menu_set_show_headers(menu, app->settings.show_headers);
 
     ItemsView* items_view = app->items_view;
-    FURI_LOG_I(TAG, "items on_enter: [%d] %s", app->depth, furi_string_get_cstr(items_view->path));
+    FURI_LOG_I(
+        TAG, "Generating scene: [depth=%d] %s", app->depth, furi_string_get_cstr(items_view->path));
 
-    const char* header = furi_string_get_cstr(items_view->name);
-    button_menu_set_header(menu, header);
+    action_menu_set_header(menu, furi_string_get_cstr(items_view->name));
 
-    if(ItemArray_size(items_view->items)) {
+    size_t item_view_size = ItemArray_size(items_view->items);
+    if(item_view_size > 0) {
         ItemArray_it_t iter;
         int32_t index = 0;
         for(ItemArray_it(iter, items_view->items); !ItemArray_end_p(iter);
             ItemArray_next(iter), ++index) {
             const char* label = furi_string_get_cstr(ItemArray_cref(iter)->name);
-            ButtonMenuItemType type = ItemArray_cref(iter)->type == Item_Action ?
-                                          ButtonMenuItemTypeCommon :
-                                          ButtonMenuItemTypeControl;
-            button_menu_add_item(menu, label, index, scene_items_item_callback, type, app);
+            ActionMenuItemType type = ItemToMenuItem[ItemArray_cref(iter)->type];
+            action_menu_add_item(menu, label, index, scene_items_item_callback, type, app);
         }
     } else {
         FURI_LOG_W(TAG, "No items for: %s", furi_string_get_cstr(items_view->path));
         // TODO: Display Error popup? Empty folder?
     }
-    // ...
 
-    view_dispatcher_switch_to_view(app->view_dispatcher, SR_ButtonMenu);
+    // Always add the "Settings" item at the end of our list - but only at top level!
+    if(app->depth == 0) {
+        action_menu_add_item(
+            menu,
+            "Settings",
+            item_view_size, // last item!
+            scene_items_item_callback,
+            ActionMenuItemTypeSettings,
+            app);
+    }
+
+    view_dispatcher_switch_to_view(app->view_dispatcher, Q_ActionMenu);
 }
 bool scene_items_on_event(void* context, SceneManagerEvent event) {
     App* app = context;
     bool consumed = false;
 
-    FURI_LOG_I(TAG, "device on_event");
     switch(event.type) {
     case SceneManagerEventTypeCustom:
         if(event.event == Event_ButtonPressed) {
             consumed = true;
-            FURI_LOG_I(TAG, "button pressed is %d", app->selected_item);
-            Item* item = ItemArray_get(app->items_view->items, app->selected_item);
-            if(item->type == Item_Group) {
-                app->depth++;
-                ItemsView* new_items = item_get_items_view_from_path(app, item->path);
-                item_items_view_free(app->items_view);
-                app->items_view = new_items;
-                scene_manager_next_scene(app->scene_manager, SR_Scene_Items);
-            } else {
-                FURI_LOG_I(TAG, "Initiating item action: %s", furi_string_get_cstr(item->name));
-
-                // LED goes blinky blinky
-                App* app = context;
-                notification_message(app->notifications, &sequence_blink_start_blue);
-
-                // Prepare error string for action calls
-                FuriString* error;
-                error = furi_string_alloc();
-
-                action_tx(app, item, error);
-
-                if(furi_string_size(error)) {
-                    FURI_LOG_E(TAG, furi_string_get_cstr(error));
-                    // Change LED to Red and Vibrate!
-                    notification_message(app->notifications, &sequence_error);
-
-                    // Display DialogEx popup or something?
+            // furi_delay_ms(100);
+            // FURI_LOG_I(TAG, "button pressed is %d", app->selected_item);
+            if(app->selected_item < (int)ItemArray_size(app->items_view->items)) {
+                Item* item = ItemArray_get(app->items_view->items, app->selected_item);
+                if(item->type == Item_Group) {
+                    app->depth++;
+                    ItemsView* new_items = item_get_items_view_from_path(app, item->path);
+                    item_items_view_free(app->items_view);
+                    app->items_view = new_items;
+                    scene_manager_next_scene(app->scene_manager, Q_Scene_Items);
+                } else {
+                    FURI_LOG_I(
+                        TAG, "Initiating item action: %s", furi_string_get_cstr(item->name));
+
+                    // LED goes blinky blinky
+                    App* app = context;
+                    notification_message(app->notifications, &sequence_blink_start_blue);
+
+                    // Prepare error string for action calls
+                    FuriString* error;
+                    error = furi_string_alloc();
+
+                    action_tx(app, item, error);
+
+                    if(furi_string_size(error)) {
+                        FURI_LOG_E(TAG, furi_string_get_cstr(error));
+                        // Change LED to Red and Vibrate!
+                        notification_message(app->notifications, &sequence_error);
+
+                        // Display DialogEx popup or something?
+                    }
+
+                    furi_string_free(error);
+
+                    // Turn off LED light
+                    notification_message(app->notifications, &sequence_blink_stop);
                 }
-
-                furi_string_free(error);
-
-                // Turn off LED light
-                notification_message(app->notifications, &sequence_blink_stop);
+            } else {
+                // FURI_LOG_I(TAG, "Selected Settings!");
+                // TODO: Do we need to free this current items_view??
+                scene_manager_next_scene(app->scene_manager, Q_Scene_Settings);
             }
         }
         break;
     case SceneManagerEventTypeBack:
-        FURI_LOG_I(TAG, "Back button pressed!");
+        // FURI_LOG_I(TAG, "Back button pressed!");
         consumed = false; // Ensure Back event continues to propagate
-        if(app->depth >= 0) {
+        if(app->depth > 0) {
             // take our current ItemsView path, and go back up a level
             FuriString* parent_path;
             parent_path = furi_string_alloc();
@@ -118,21 +149,19 @@ bool scene_items_on_event(void* context, SceneManagerEvent event) {
 
             furi_string_free(parent_path);
         } else {
-            FURI_LOG_W(TAG, "At the root level!");
+            // FURI_LOG_I(TAG, "At the root level!");
         }
         break;
     default:
+        FURI_LOG_I(TAG, "Custom event not handled");
         break;
     }
+    // FURI_LOG_I(TAG, "Generic event not handled");
     return consumed;
 }
 
 void scene_items_on_exit(void* context) {
     App* app = context;
-    ButtonMenu* menu = app->btn_menu;
-    button_menu_reset(menu);
-    DialogEx* dialog = app->dialog;
-    dialog_ex_reset(dialog);
-
-    FURI_LOG_I(TAG, "on_exit. depth = %d", app->depth);
+    ActionMenu* menu = app->action_menu;
+    action_menu_reset(menu);
 }

+ 127 - 0
scenes/scene_settings.c

@@ -0,0 +1,127 @@
+#include <furi.h>
+
+#include <gui/view_dispatcher.h>
+#include <gui/scene_manager.h>
+#include <gui/modules/variable_item_list.h>
+#include <toolbox/value_index.h>
+
+#include "quac.h"
+#include "scenes.h"
+#include "scene_settings.h"
+#include "../actions/action.h"
+#include "../views/action_menu.h"
+#include "../quac_settings.h"
+
+#include <lib/toolbox/path.h>
+
+static const char* const layout_text[2] = {"Vert", "Horiz"};
+static const uint32_t layout_value[2] = {QUAC_APP_PORTRAIT, QUAC_APP_LANDSCAPE};
+
+static const char* const show_icons_text[2] = {"OFF", "ON"};
+static const uint32_t show_icons_value[2] = {false, true};
+
+static const char* const show_headers_text[2] = {"OFF", "ON"};
+static const uint32_t show_headers_value[2] = {false, true};
+
+#define V_RFID_DURATION_COUNT 8
+static const char* const rfid_duration_text[V_RFID_DURATION_COUNT] = {
+    "500 ms",
+    "1 sec",
+    "1.5 sec",
+    "2 sec",
+    "2.5 sec",
+    "3 sec",
+    "5 sec",
+    "10 sec",
+};
+static const uint32_t rfid_duration_value[V_RFID_DURATION_COUNT] = {
+    500,
+    1000,
+    1500,
+    2000,
+    2500,
+    3000,
+    5000,
+    10000,
+};
+
+static void scene_settings_layout_changed(VariableItem* item) {
+    App* app = variable_item_get_context(item);
+    uint8_t index = variable_item_get_current_value_index(item);
+    variable_item_set_current_value_text(item, layout_text[index]);
+    app->settings.layout = layout_value[index];
+}
+
+static void scene_settings_show_icons_changed(VariableItem* item) {
+    App* app = variable_item_get_context(item);
+    uint8_t index = variable_item_get_current_value_index(item);
+    variable_item_set_current_value_text(item, show_icons_text[index]);
+    app->settings.show_icons = show_icons_value[index];
+}
+
+static void scene_settings_show_headers_changed(VariableItem* item) {
+    App* app = variable_item_get_context(item);
+    uint8_t index = variable_item_get_current_value_index(item);
+    variable_item_set_current_value_text(item, show_headers_text[index]);
+    app->settings.show_headers = show_headers_value[index];
+}
+
+static void scene_settings_rfid_duration_changed(VariableItem* item) {
+    App* app = variable_item_get_context(item);
+    uint8_t index = variable_item_get_current_value_index(item);
+    variable_item_set_current_value_text(item, rfid_duration_text[index]);
+    app->settings.rfid_duration = rfid_duration_value[index];
+}
+
+// For each scene, implement handler callbacks
+void scene_settings_on_enter(void* context) {
+    App* app = context;
+
+    VariableItemList* vil = app->vil_settings;
+    variable_item_list_reset(vil);
+
+    VariableItem* item;
+    uint8_t value_index;
+
+    item = variable_item_list_add(vil, "Layout", 2, scene_settings_layout_changed, app);
+    value_index = value_index_uint32(app->settings.layout, layout_value, 2);
+    variable_item_set_current_value_index(item, value_index);
+    variable_item_set_current_value_text(item, layout_text[value_index]);
+
+    item = variable_item_list_add(vil, "Show Icons", 2, scene_settings_show_icons_changed, app);
+    value_index = value_index_uint32(app->settings.show_icons, show_icons_value, 2);
+    variable_item_set_current_value_index(item, value_index);
+    variable_item_set_current_value_text(item, show_icons_text[value_index]);
+
+    item =
+        variable_item_list_add(vil, "Show Headers", 2, scene_settings_show_headers_changed, app);
+    value_index = value_index_uint32(app->settings.show_headers, show_headers_value, 2);
+    variable_item_set_current_value_index(item, value_index);
+    variable_item_set_current_value_text(item, show_headers_text[value_index]);
+
+    item = variable_item_list_add(
+        vil, "RFID Duration", V_RFID_DURATION_COUNT, scene_settings_rfid_duration_changed, app);
+    value_index = value_index_uint32(
+        app->settings.rfid_duration, rfid_duration_value, V_RFID_DURATION_COUNT);
+    variable_item_set_current_value_index(item, value_index);
+    variable_item_set_current_value_text(item, rfid_duration_text[value_index]);
+
+    // TODO: Set Enter callback here - why?? All settings have custom callbacks
+    // variable_item_list_set_enter_callback(vil, my_cb, app);
+
+    view_dispatcher_switch_to_view(app->view_dispatcher, Q_Settings);
+}
+bool scene_settings_on_event(void* context, SceneManagerEvent event) {
+    UNUSED(context);
+    UNUSED(event);
+
+    return false;
+}
+
+void scene_settings_on_exit(void* context) {
+    App* app = context;
+    VariableItemList* vil = app->vil_settings;
+    variable_item_list_reset(vil);
+
+    quac_save_settings(app);
+}

+ 10 - 0
scenes/scene_settings.h

@@ -0,0 +1,10 @@
+#pragma once
+
+#include <gui/scene_manager.h>
+
+// void scene_settings_item_callback(void* context, int32_t index, InputType type);
+
+// For each scene, implement handler callbacks
+void scene_settings_on_enter(void* context);
+bool scene_settings_on_event(void* context, SceneManagerEvent event);
+void scene_settings_on_exit(void* context);

+ 7 - 3
scenes/scenes.c

@@ -3,19 +3,23 @@
 #include "quac.h"
 #include "scenes.h"
 #include "scene_items.h"
+#include "scene_settings.h"
 
 // define handler callbacks - order must match appScenes enum!
-void (*const app_on_enter_handlers[])(void* context) = {scene_items_on_enter};
+void (*const app_on_enter_handlers[])(void* context) = {
+    scene_items_on_enter,
+    scene_settings_on_enter};
 bool (*const app_on_event_handlers[])(void* context, SceneManagerEvent event) = {
     scene_items_on_event,
+    scene_settings_on_event,
 };
-void (*const app_on_exit_handlers[])(void* context) = {scene_items_on_exit};
+void (*const app_on_exit_handlers[])(void* context) = {scene_items_on_exit, scene_settings_on_exit};
 
 const SceneManagerHandlers app_scene_handlers = {
     .on_enter_handlers = app_on_enter_handlers,
     .on_event_handlers = app_on_event_handlers,
     .on_exit_handlers = app_on_exit_handlers,
-    .scene_num = SR_Scene_count};
+    .scene_num = Q_Scene_count};
 
 bool app_scene_custom_callback(void* context, uint32_t custom_event_id) {
     App* app = context;

+ 4 - 5
scenes/scenes.h

@@ -1,12 +1,11 @@
 #pragma once
 
-typedef enum { SR_Scene_Items, SR_Scene_count } appScenes;
+typedef enum { Q_Scene_Items, Q_Scene_Settings, Q_Scene_count } appScenes;
 
 typedef enum {
-    SR_ButtonMenu, // used on selected device, to show buttons/groups
-    SR_Dialog, // shows errors
-    SR_FileBrowser, // TODO: UNUSED!
-    SR_TextInput // TODO: UNUSED
+    Q_ActionMenu, // new UI,
+    Q_Settings, // Variable Item List for settings
+    Q_Dialog, // TODO: shows errors
 } appView;
 
 typedef enum { Event_DeviceSelected, Event_ButtonPressed } AppCustomEvents;

+ 541 - 0
views/action_menu.c

@@ -0,0 +1,541 @@
+#include "action_menu.h"
+
+#include <gui/canvas.h>
+#include <gui/elements.h>
+#include <input/input.h>
+
+#include <furi.h>
+
+#include <stdint.h>
+#include <m-array.h>
+
+#include "quac_icons.h"
+
+#define ITEM_FIRST_OFFSET 17
+#define ITEM_NEXT_OFFSET 4
+#define ITEM_HEIGHT 14
+#define ITEM_WIDTH 64
+#define BUTTONS_PER_SCREEN 6
+
+#define ITEMS_PER_SCREEN_LANDSCAPE 3
+#define ITEMS_PER_SCREEN_PORTRAIT 6
+
+static const Icon* ActionMenuIcons[] = {
+    [ActionMenuItemTypeSubGHz] = &I_SubGHz_10px,
+    [ActionMenuItemTypeRFID] = &I_RFID_10px,
+    [ActionMenuItemTypeIR] = &I_IR_10px,
+    [ActionMenuItemTypePlaylist] = &I_Playlist_10px,
+    [ActionMenuItemTypeGroup] = &I_Directory_10px,
+    [ActionMenuItemTypeSettings] = &I_Settings_10px,
+};
+
+struct ActionMenuItem {
+    const char* label;
+    uint32_t index;
+    ActionMenuItemCallback callback;
+    ActionMenuItemType type;
+    void* callback_context;
+};
+
+ARRAY_DEF(ActionMenuItemArray, ActionMenuItem, M_POD_OPLIST);
+#define M_OPL_ActionMenuItemArray_t() ARRAY_OPLIST(ActionMenuItemArray, M_POD_OPLIST)
+
+struct ActionMenu {
+    View* view;
+    bool freeze_input;
+};
+
+typedef struct {
+    ActionMenuItemArray_t items;
+    size_t position;
+    size_t window_position;
+    FuriString* header;
+    ActionMenuLayout layout;
+    bool show_icons;
+    bool show_headers;
+} ActionMenuModel;
+
+static void action_menu_draw_landscape(Canvas* canvas, ActionMenuModel* model) {
+    const uint8_t item_height = 16;
+    uint8_t item_width = canvas_width(canvas) - 5; // space for scrollbar
+
+    const bool have_header = furi_string_size(model->header) && model->show_headers;
+
+    canvas_clear(canvas);
+    if(have_header) {
+        canvas_set_font(canvas, FontPrimary);
+        canvas_draw_str(canvas, 4, 11, furi_string_get_cstr(model->header));
+    }
+    canvas_set_font(canvas, FontSecondary);
+
+    size_t position = 0;
+    const size_t items_on_screen = ITEMS_PER_SCREEN_LANDSCAPE + (have_header ? 0 : 1);
+    uint8_t y_offset = have_header ? 16 : 0;
+    const size_t x_txt_start = model->show_icons ? 18 : 4;
+
+    ActionMenuItemArray_it_t it;
+    for(ActionMenuItemArray_it(it, model->items); !ActionMenuItemArray_end_p(it);
+        ActionMenuItemArray_next(it)) {
+        const size_t item_position = position - model->window_position;
+
+        if(item_position < items_on_screen) {
+            if(position == model->position) {
+                canvas_set_color(canvas, ColorBlack);
+                elements_slightly_rounded_box(
+                    canvas,
+                    0,
+                    y_offset + (item_position * item_height) + 1,
+                    item_width,
+                    item_height - 2);
+                canvas_set_color(canvas, ColorWhite);
+            } else {
+                canvas_set_color(canvas, ColorBlack);
+            }
+
+            const ActionMenuItem* item = ActionMenuItemArray_cref(it);
+            if(model->show_icons) {
+                canvas_draw_icon(
+                    canvas,
+                    4,
+                    y_offset + (item_position * item_height) + 3,
+                    ActionMenuIcons[item->type]);
+            }
+
+            FuriString* disp_str;
+            disp_str = furi_string_alloc_set(item->label);
+            elements_string_fit_width(canvas, disp_str, item_width - (6 * 2));
+
+            canvas_draw_str(
+                canvas,
+                x_txt_start, // 6
+                y_offset + (item_position * item_height) + item_height - 4,
+                furi_string_get_cstr(disp_str));
+            furi_string_free(disp_str);
+        }
+        position++;
+    }
+
+    elements_scrollbar(canvas, model->position, ActionMenuItemArray_size(model->items));
+}
+
+static void action_menu_draw_portrait(Canvas* canvas, ActionMenuModel* model) {
+    const bool have_header = furi_string_size(model->header) && model->show_headers;
+    const size_t items_per_screen = have_header ? ITEMS_PER_SCREEN_PORTRAIT :
+                                                  ITEMS_PER_SCREEN_PORTRAIT + 1;
+    const size_t active_screen = model->position / items_per_screen;
+    const size_t items_size = ActionMenuItemArray_size(model->items);
+    const size_t max_screen = items_size ? (items_size - 1) / items_per_screen : 0;
+
+    canvas_clear(canvas);
+
+    // Draw up/down arrows, as needed
+    if(active_screen > 0) {
+        canvas_draw_icon(canvas, 28, 1, &I_ArrowUp_8x4);
+    }
+    if(max_screen > active_screen) {
+        canvas_draw_icon(canvas, 28, 123, &I_ArrowDown_8x4);
+    }
+
+    if(have_header) {
+        canvas_set_font(canvas, FontPrimary);
+        elements_string_fit_width(canvas, model->header, ITEM_WIDTH - 6);
+        canvas_draw_str_aligned(
+            canvas, 32, 10, AlignCenter, AlignCenter, furi_string_get_cstr(model->header));
+    }
+    canvas_set_font(canvas, FontSecondary);
+
+    size_t item_position = 0;
+    const size_t x_txt_start = model->show_icons ? 16 : 4;
+    const size_t y_offset = have_header ? ITEM_FIRST_OFFSET : 6;
+    const size_t item_next_offset = have_header ? ITEM_NEXT_OFFSET : ITEM_NEXT_OFFSET - 1;
+
+    ActionMenuItemArray_it_t it;
+    for(ActionMenuItemArray_it(it, model->items); !ActionMenuItemArray_end_p(it);
+        ActionMenuItemArray_next(it), ++item_position) {
+        if(active_screen == (item_position / items_per_screen)) {
+            uint8_t position_offset = item_position % items_per_screen;
+            bool selected = item_position == model->position;
+
+            // draw the item
+            uint8_t item_y = y_offset + (position_offset * (ITEM_HEIGHT + item_next_offset));
+
+            canvas_set_color(canvas, ColorBlack);
+
+            if(selected) {
+                // Same as elements_slightly_rounded_box with radius of 5
+                canvas_draw_rbox(canvas, 0, item_y, ITEM_WIDTH, ITEM_HEIGHT, 1);
+                canvas_set_color(canvas, ColorWhite);
+            } else {
+                canvas_draw_rframe(canvas, 0, item_y, ITEM_WIDTH, ITEM_HEIGHT, 1);
+            }
+
+            const ActionMenuItem* item = ActionMenuItemArray_cref(it);
+            if(model->show_icons) {
+                canvas_draw_icon(canvas, 3, item_y + 2, ActionMenuIcons[item->type]);
+            }
+
+            FuriString* disp_str;
+            disp_str = furi_string_alloc_set(item->label);
+            elements_string_fit_width(canvas, disp_str, ITEM_WIDTH - 6);
+
+            canvas_draw_str(
+                canvas,
+                x_txt_start,
+                item_y + (ITEM_HEIGHT / 2) + 3,
+                furi_string_get_cstr(disp_str));
+            furi_string_free(disp_str);
+        }
+    }
+}
+
+static void action_menu_view_draw_callback(Canvas* canvas, void* context) {
+    furi_assert(canvas);
+    ActionMenuModel* model = (ActionMenuModel*)context;
+
+    if(model->layout == ActionMenuLayoutLandscape) {
+        action_menu_draw_landscape(canvas, model);
+    } else {
+        action_menu_draw_portrait(canvas, model);
+    }
+}
+
+static void action_menu_process_up(ActionMenu* action_menu) {
+    furi_assert(action_menu);
+
+    with_view_model(
+        action_menu->view,
+        ActionMenuModel * model,
+        {
+            const size_t items_size = ActionMenuItemArray_size(model->items);
+            if(model->layout == ActionMenuLayoutPortrait) {
+                if(model->position > 0) {
+                    model->position--;
+                } else {
+                    model->position = items_size - 1;
+                }
+            } else {
+                const size_t items_on_screen = furi_string_empty(model->header) ? 4 : 3;
+                if(model->position > 0) {
+                    model->position--;
+                    if((model->position == model->window_position) &&
+                       (model->window_position > 0)) {
+                        model->window_position--;
+                    }
+                } else {
+                    model->position = items_size - 1;
+                    if(model->position > items_on_screen - 1) {
+                        model->window_position = model->position - (items_on_screen - 1);
+                    }
+                }
+            }
+        },
+        true);
+}
+
+static void action_menu_process_down(ActionMenu* action_menu) {
+    furi_assert(action_menu);
+
+    with_view_model(
+        action_menu->view,
+        ActionMenuModel * model,
+        {
+            const size_t items_size = ActionMenuItemArray_size(model->items);
+            if(model->layout == ActionMenuLayoutPortrait) {
+                if(model->position < items_size - 1) {
+                    model->position++;
+                } else {
+                    model->position = 0;
+                }
+            } else {
+                const size_t items_on_screen = furi_string_empty(model->header) ? 4 : 3;
+                if(model->position < items_size - 1) {
+                    model->position++;
+                    if((model->position - model->window_position > items_on_screen - 2) &&
+                       (model->window_position < items_size - items_on_screen)) {
+                        model->window_position++;
+                    }
+                } else {
+                    model->position = 0;
+                    model->window_position = 0;
+                }
+            }
+        },
+        true);
+}
+
+static void action_menu_process_ok(ActionMenu* action_menu, InputType type) {
+    furi_assert(action_menu);
+
+    // FURI_LOG_I("AM", "OK pressed! %d: %s", type, input_get_type_name(type));
+    ActionMenuItem* item = NULL;
+
+    with_view_model(
+        action_menu->view,
+        ActionMenuModel * model,
+        {
+            if(model->position < (ActionMenuItemArray_size(model->items))) {
+                item = ActionMenuItemArray_get(model->items, model->position);
+            }
+        },
+        false);
+
+    // Landscape: Press, Short, Release
+
+    if(item) {
+        if(type == InputTypeRelease && item->callback)
+            item->callback(item->callback_context, item->index, type);
+    }
+}
+
+static bool action_menu_view_input_callback(InputEvent* event, void* context) {
+    furi_assert(event);
+
+    ActionMenu* action_menu = context;
+    bool consumed = false;
+
+    // Item selection
+    if(event->key == InputKeyOk) {
+        if((event->type == InputTypeRelease) || (event->type == InputTypePress)) {
+            consumed = true;
+            action_menu->freeze_input = (event->type == InputTypePress);
+            action_menu_process_ok(action_menu, event->type);
+        } else if(event->type == InputTypeShort) {
+            consumed = true;
+            action_menu_process_ok(action_menu, event->type);
+        }
+    }
+
+    if(!action_menu->freeze_input &&
+       ((event->type == InputTypeRepeat) || (event->type == InputTypeShort))) {
+        // FURI_LOG_I("AM", "Directional key: %d", event->key);
+        switch(event->key) {
+        case InputKeyUp:
+            consumed = true;
+            action_menu_process_up(action_menu);
+            break;
+        case InputKeyDown:
+            consumed = true;
+            action_menu_process_down(action_menu);
+            break;
+        case InputKeyRight:
+            FURI_LOG_W("AM", "InputKeyRight ignored");
+            // consumed = true;
+            // action_menu_process_right(action_menu);
+            break;
+        case InputKeyLeft:
+            FURI_LOG_W("AM", "InputKeyLeft ignored");
+            // consumed = true;
+            // action_menu_process_left(action_menu);
+            break;
+        default:
+            break;
+        }
+    }
+
+    return consumed;
+}
+
+View* action_menu_get_view(ActionMenu* action_menu) {
+    furi_assert(action_menu);
+    return action_menu->view;
+}
+
+void action_menu_reset(ActionMenu* action_menu) {
+    furi_assert(action_menu);
+
+    with_view_model(
+        action_menu->view,
+        ActionMenuModel * model,
+        {
+            // for
+            //     M_EACH(item, model->items, ActionMenuItemArray_t) {
+            //         icon_animation_stop(item->icon);
+            //         icon_animation_free(item->icon);
+            //     }
+            ActionMenuItemArray_reset(model->items);
+            model->position = 0;
+            model->window_position = 0;
+            furi_string_reset(model->header);
+        },
+        true);
+}
+
+void action_menu_set_layout(ActionMenu* action_menu, ActionMenuLayout layout) {
+    furi_assert(action_menu);
+
+    with_view_model(
+        action_menu->view,
+        ActionMenuModel * model,
+        {
+            model->layout = layout;
+            if(model->layout == ActionMenuLayoutLandscape) {
+                view_set_orientation(action_menu->view, ViewOrientationHorizontal);
+            } else {
+                view_set_orientation(action_menu->view, ViewOrientationVertical);
+            }
+        },
+        true);
+}
+
+void action_menu_set_header(ActionMenu* action_menu, const char* header) {
+    furi_assert(action_menu);
+    with_view_model(
+        action_menu->view,
+        ActionMenuModel * model,
+        {
+            if(header == NULL) {
+                furi_string_reset(model->header);
+            } else {
+                furi_string_set_str(model->header, header);
+            }
+        },
+        true);
+}
+
+void action_menu_set_show_icons(ActionMenu* action_menu, bool show_icons) {
+    with_view_model(
+        action_menu->view, ActionMenuModel * model, { model->show_icons = show_icons; }, true);
+}
+
+void action_menu_set_show_headers(ActionMenu* action_menu, bool show_headers) {
+    with_view_model(
+        action_menu->view, ActionMenuModel * model, { model->show_headers = show_headers; }, true);
+}
+
+ActionMenuItem* action_menu_add_item(
+    ActionMenu* action_menu,
+    const char* label,
+    int32_t index,
+    ActionMenuItemCallback callback,
+    ActionMenuItemType type,
+    void* callback_context) {
+    ActionMenuItem* item = NULL;
+    furi_assert(label);
+    furi_assert(action_menu);
+
+    with_view_model(
+        action_menu->view,
+        ActionMenuModel * model,
+        {
+            item = ActionMenuItemArray_push_new(model->items);
+            item->label = label;
+            // item->icon = icon ? icon_animation_alloc(icon) : NULL; // or default icon?
+            // view_tie_icon_animation(action_menu->view, item->icon);
+            item->index = index;
+            item->type = type;
+            item->callback = callback;
+            item->callback_context = callback_context;
+        },
+        true);
+
+    return item;
+}
+
+ActionMenu* action_menu_alloc(void) {
+    ActionMenu* action_menu = malloc(sizeof(ActionMenu));
+    action_menu->view = view_alloc();
+    view_set_orientation(action_menu->view, ViewOrientationHorizontal);
+    view_set_context(action_menu->view, action_menu);
+    view_allocate_model(action_menu->view, ViewModelTypeLocking, sizeof(ActionMenuModel));
+    view_set_draw_callback(action_menu->view, action_menu_view_draw_callback);
+    view_set_input_callback(action_menu->view, action_menu_view_input_callback);
+
+    with_view_model(
+        action_menu->view,
+        ActionMenuModel * model,
+        {
+            ActionMenuItemArray_init(model->items);
+            model->position = 0;
+            model->window_position = 0;
+            model->header = furi_string_alloc();
+            model->layout = ActionMenuLayoutLandscape; // TODO: ehhhhhhhhhhhhhhhhhhh
+            model->show_icons = true;
+            model->show_headers = true;
+        },
+        true);
+
+    action_menu->freeze_input = false;
+    return action_menu;
+}
+
+void action_menu_free(ActionMenu* action_menu) {
+    furi_assert(action_menu);
+
+    with_view_model(
+        action_menu->view,
+        ActionMenuModel * model,
+        {
+            // for
+            //     M_EACH(item, model->items, ActionMenuItemArray_t) {
+            //         icon_animation_stop(item->icon);
+            //         icon_animation_free(item->icon);
+            //     }
+            ActionMenuItemArray_clear(model->items);
+            furi_string_free(model->header);
+        },
+        true);
+    view_free(action_menu->view);
+    free(action_menu);
+}
+
+void action_menu_set_selected_item(ActionMenu* action_menu, uint32_t index) {
+    furi_assert(action_menu);
+
+    ActionMenuModel* m = view_get_model(action_menu->view);
+    if(m->layout == ActionMenuLayoutPortrait) {
+        with_view_model(
+            action_menu->view,
+            ActionMenuModel * model,
+            {
+                size_t item_position = 0;
+                ActionMenuItemArray_it_t it;
+                for(ActionMenuItemArray_it(it, model->items); !ActionMenuItemArray_end_p(it);
+                    ActionMenuItemArray_next(it), ++item_position) {
+                    if((uint32_t)ActionMenuItemArray_cref(it)->index == index) {
+                        model->position = item_position;
+                        break;
+                    }
+                }
+            },
+            true);
+    } else {
+        with_view_model(
+            action_menu->view,
+            ActionMenuModel * model,
+            {
+                size_t position = 0;
+                ActionMenuItemArray_it_t it;
+                for(ActionMenuItemArray_it(it, model->items); !ActionMenuItemArray_end_p(it);
+                    ActionMenuItemArray_next(it)) {
+                    if(index == ActionMenuItemArray_cref(it)->index) {
+                        break;
+                    }
+                    position++;
+                }
+                const size_t items_size = ActionMenuItemArray_size(model->items);
+
+                if(position >= items_size) {
+                    position = 0;
+                }
+
+                model->position = position;
+                model->window_position = position;
+
+                if(model->window_position > 0) {
+                    model->window_position -= 1;
+                }
+
+                const size_t items_on_screen = furi_string_empty(model->header) ? 4 : 3;
+
+                if(items_size <= items_on_screen) {
+                    model->window_position = 0;
+                } else {
+                    const size_t pos = items_size - items_on_screen;
+                    if(model->window_position > pos) {
+                        model->window_position = pos;
+                    }
+                }
+            },
+            true);
+    }
+}

+ 118 - 0
views/action_menu.h

@@ -0,0 +1,118 @@
+#pragma once
+
+#include <stdint.h>
+#include <gui/view.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/** ActionMenu anonymous structure */
+typedef struct ActionMenu ActionMenu;
+
+/** ActionMenuItem anonymous structure */
+typedef struct ActionMenuItem ActionMenuItem;
+
+/** Callback for any button menu actions */
+typedef void (*ActionMenuItemCallback)(void* context, int32_t index, InputType type);
+
+/** Type of UI element */
+typedef enum {
+    ActionMenuItemTypeSubGHz,
+    ActionMenuItemTypeRFID,
+    ActionMenuItemTypeIR,
+    ActionMenuItemTypePlaylist,
+    ActionMenuItemTypeGroup,
+    ActionMenuItemTypeSettings,
+    ActionMenuItemType_count
+} ActionMenuItemType;
+
+typedef enum {
+    ActionMenuLayoutPortrait,
+    ActionMenuLayoutLandscape,
+} ActionMenuLayout;
+
+/** Get button menu view
+ *
+ * @param      action_menu  ActionMenu instance
+ *
+ * @return     View instance that can be used for embedding
+ */
+View* action_menu_get_view(ActionMenu* action_menu);
+
+/** Clean button menu
+ *
+ * @param      action_menu  ActionMenu instance
+ */
+void action_menu_reset(ActionMenu* action_menu);
+
+/** Set the layout
+ * 
+ * @param      layout       Portrait or Landscape
+*/
+void action_menu_set_layout(ActionMenu* menu, ActionMenuLayout layout);
+
+/** Show/Hide icons in UI
+ * 
+ * @param      show_icons   Show or Hide icons
+*/
+void action_menu_set_show_icons(ActionMenu* menu, bool show_icons);
+
+/** Show/Hide header labels in UI
+ * 
+ * @param      show_headers   Show or Hide header labels
+*/
+void action_menu_set_show_headers(ActionMenu* menu, bool show_headers);
+
+/** Add item to button menu instance
+ *
+ * @param      action_menu       ActionMenu instance
+ * @param      label             text inside new button
+ * @param      icon              IconAnimation instance
+ * @param      index             value to distinct between buttons inside
+ *                               ActionMenuItemCallback
+ * @param      callback          The callback
+ * @param      type              type of button to create. Differ by button
+ *                               drawing. Control buttons have no frames, and
+ *                               have more squared borders.
+ * @param      callback_context  The callback context
+ *
+ * @return     pointer to just-created item
+ */
+ActionMenuItem* action_menu_add_item(
+    ActionMenu* action_menu,
+    const char* label,
+    int32_t index,
+    ActionMenuItemCallback callback,
+    ActionMenuItemType type,
+    void* callback_context);
+
+/** Allocate and initialize new instance of ActionMenu model
+ *
+ * @return     just-created ActionMenu model
+ */
+ActionMenu* action_menu_alloc(void);
+
+/** Free ActionMenu element
+ *
+ * @param      action_menu  ActionMenu instance
+ */
+void action_menu_free(ActionMenu* action_menu);
+
+/** Set ActionMenu header on top of canvas
+ *
+ * @param      action_menu  ActionMenu instance
+ * @param      header       header on the top of button menu
+ */
+void action_menu_set_header(ActionMenu* action_menu, const char* header);
+
+/** Set selected item
+ *
+ * @param      action_menu  ActionMenu instance
+ * @param      index        index of ActionMenu to be selected
+ */
+void action_menu_set_selected_item(ActionMenu* action_menu, uint32_t index);
+
+#ifdef __cplusplus
+}
+#endif